Improve caption editor usability, new 'add attachment' affordance

This commit is contained in:
Scott Nonnenberg 2019-01-15 09:33:23 -08:00
parent ac1a6d197a
commit 0de54e125c
14 changed files with 224 additions and 61 deletions

View file

@ -952,6 +952,11 @@
"descripton": "descripton":
"Used as the placeholder text in the caption editor text field" "Used as the placeholder text in the caption editor text field"
}, },
"save": {
"message": "Save",
"descripton":
"Used as a 'commit changes' button in the Caption Editor for outgoing image attachments"
},
"fileIconAlt": { "fileIconAlt": {
"message": "File icon", "message": "File icon",
"description": "description":

1
images/plus-36.svg Normal file
View file

@ -0,0 +1 @@
<svg id="Export" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><title>plus-36</title><polygon points="32 17.25 18.75 17.25 18.75 4 17.25 4 17.25 17.25 4 17.25 4 18.75 17.25 18.75 17.25 32 18.75 32 18.75 18.75 32 18.75 32 17.25"/></svg>

After

Width:  |  Height:  |  Size: 244 B

View file

@ -156,6 +156,11 @@
'attachments-changed', 'attachments-changed',
this.toggleMicrophone this.toggleMicrophone
); );
this.listenTo(
this.fileInput,
'choose-attachment',
this.onChooseAttachment
);
const getHeaderProps = () => { const getHeaderProps = () => {
const expireTimer = this.model.get('expireTimer'); const expireTimer = this.model.get('expireTimer');
@ -275,8 +280,10 @@
}, },
onChooseAttachment(e) { onChooseAttachment(e) {
e.stopPropagation(); if (e) {
e.preventDefault(); e.stopPropagation();
e.preventDefault();
}
this.$('input.file-input').click(); this.$('input.file-input').click();
}, },

View file

@ -86,6 +86,7 @@
return { return {
attachments, attachments,
onAddAttachment: this.onAddAttachment.bind(this),
onClickAttachment: this.onClickAttachment.bind(this), onClickAttachment: this.onClickAttachment.bind(this),
onCloseAttachment: this.onCloseAttachment.bind(this), onCloseAttachment: this.onCloseAttachment.bind(this),
onClose: this.onClose.bind(this), onClose: this.onClose.bind(this),
@ -97,18 +98,15 @@
url: attachment.videoUrl || attachment.url, url: attachment.videoUrl || attachment.url,
caption: attachment.caption, caption: attachment.caption,
attachment, attachment,
onChangeCaption, onSave,
}); });
const update = () => { const onSave = caption => {
this.captionEditorView.update(getProps());
};
const onChangeCaption = caption => {
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
attachment.caption = caption; attachment.caption = caption;
this.captionEditorView.remove();
Signal.Backbone.Views.Lightbox.hide();
this.render(); this.render();
update();
}; };
this.captionEditorView = new Whisper.ReactWrapperView({ this.captionEditorView = new Whisper.ReactWrapperView({
@ -126,6 +124,10 @@
this.render(); this.render();
}, },
onAddAttachment() {
this.trigger('choose-attachment');
},
onClose() { onClose() {
this.attachments = []; this.attachments = [];
this.trigger('attachments-changed'); this.trigger('attachments-changed');

View file

@ -412,15 +412,6 @@ $loading-height: 16px;
} }
} }
input[type='text'],
input[type='search'],
textarea {
&:active,
&:focus {
outline: 1px solid $blue;
}
}
.expiredAlert { .expiredAlert {
background: #f3f3a7; background: #f3f3a7;
padding: 10px; padding: 10px;

View file

@ -2298,7 +2298,7 @@
height: 20px; height: 20px;
z-index: 2; z-index: 2;
@include color-svg('../images/x.svg', $color-black); @include color-svg('../images/x-16.svg', $color-black);
} }
.module-attachments__rail { .module-attachments__rail {
@ -2416,7 +2416,7 @@
width: 30px; width: 30px;
height: 30px; height: 30px;
z-index: 2; z-index: 2;
@include color-svg('../images/x.svg', $color-white); @include color-svg('../images/x-16.svg', $color-white);
} }
.module-caption-editor__media-container { .module-caption-editor__media-container {
@ -2457,8 +2457,8 @@
.module-caption-editor__bottom-bar { .module-caption-editor__bottom-bar {
flex-grow: 0; flex-grow: 0;
flex-shrink: 0; flex-shrink: 0;
height: 3em; height: 52px;
padding: 0.5em; padding: 8px;
display: inline-flex; display: inline-flex;
flex-direction: row; flex-direction: row;
@ -2468,23 +2468,23 @@
margin-right: auto; margin-right: auto;
} }
.module-caption-editor__add-caption-button { .module-caption-editor__input-container {
display: inline-block; position: relative;
margin-left: 6px;
height: 24px;
width: 24px;
margin-right: 6px;
@include color-svg('../images/add-caption-24.svg', $color-white);
} }
.module-caption-editor__caption-input { .module-caption-editor__caption-input {
height: 2em; height: 36px;
width: 40em; width: 40em;
border: 1px solid $color-white;
border-radius: 1em; font-size: 14px;
color: $color-white; color: $color-white;
border: 1px solid $color-white;
border-radius: 18px;
background-color: $color-black; background-color: $color-black;
padding: 0.5em; padding: 9px;
padding-left: 12px;
padding-right: 65px;
&::placeholder { &::placeholder {
color: $color-white-07; color: $color-white-07;
@ -2495,6 +2495,54 @@
} }
} }
.module-caption-editor__save-button {
position: absolute;
background-color: $color-signal-blue;
color: $color-white;
cursor: pointer;
height: 28px;
border-radius: 15px;
padding: 5px;
padding-left: 12px;
padding-right: 12px;
right: 4px;
top: 4px;
}
// Module: Staged Placeholder Attachment
.module-staged-placeholder-attachment {
margin: 1px;
border-radius: 4px;
border: 1px solid $color-gray-25;
height: 120px;
width: 120px;
display: inline-block;
vertical-align: middle;
cursor: pointer;
position: relative;
&:hover {
background: $color-gray-05;
}
}
.module-staged-placeholder-attachment__plus-icon {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
height: 36px;
width: 36px;
@include color-svg('../images/plus-36.svg', $color-gray-45);
}
// Third-party module: react-contextmenu // Third-party module: react-contextmenu
.react-contextmenu { .react-contextmenu {

View file

@ -1350,6 +1350,20 @@ body.dark-theme {
color: $color-gray-90; color: $color-gray-90;
} }
// Module: Staged Placeholder Attachment
.module-staged-placeholder-attachment {
border: 1px solid $color-gray-60;
&:hover {
background: $color-gray-75;
}
}
.module-staged-placeholder-attachment__plus-icon {
@include color-svg('../images/plus-36.svg', $color-gray-60);
}
// Third-party module: react-contextmenu // Third-party module: react-contextmenu
.react-contextmenu { .react-contextmenu {

View file

@ -9,7 +9,8 @@ let caption = null;
attachment={{ attachment={{
contentType: 'image/jpeg', contentType: 'image/jpeg',
}} }}
onChangeCaption={caption => console.log('onChangeCaption', caption)} onSave={caption => console.log('onSave', caption)}
close={() => console.log('close')}
i18n={util.i18n} i18n={util.i18n}
/> />
</div>; </div>;
@ -29,7 +30,8 @@ let caption =
}} }}
caption={caption} caption={caption}
contentType="image/jpeg" contentType="image/jpeg"
onChangeCaption={caption => console.log('onChangeCaption', caption)} onSave={caption => console.log('onSave', caption)}
close={() => console.log('close')}
i18n={util.i18n} i18n={util.i18n}
/> />
</div>; </div>;
@ -46,7 +48,8 @@ let caption = null;
attachment={{ attachment={{
contentType: 'video/mp4', contentType: 'video/mp4',
}} }}
onChangeCaption={caption => console.log('onChangeCaption', caption)} onSave={caption => console.log('onSave', caption)}
close={() => console.log('close')}
i18n={util.i18n} i18n={util.i18n}
/> />
</div>; </div>;
@ -65,7 +68,8 @@ let caption =
contentType: 'video/mp4', contentType: 'video/mp4',
}} }}
caption={caption} caption={caption}
onChangeCaption={caption => console.log('onChangeCaption', caption)} onSave={caption => console.log('onSave', caption)}
close={() => console.log('close')}
i18n={util.i18n} i18n={util.i18n}
/> />
</div>; </div>;

View file

@ -12,31 +12,52 @@ interface Props {
i18n: Localizer; i18n: Localizer;
url: string; url: string;
caption?: string; caption?: string;
onChangeCaption?: (caption: string) => void; onSave?: (caption: string) => void;
close?: () => void; close?: () => void;
} }
export class CaptionEditor extends React.Component<Props> { interface State {
private handleKeyUpBound: () => void; caption: string;
}
export class CaptionEditor extends React.Component<Props, State> {
private handleKeyUpBound: (
event: React.KeyboardEvent<HTMLInputElement>
) => void;
private setFocusBound: () => void; private setFocusBound: () => void;
// TypeScript doesn't like our React.Ref typing here, so we omit it
private captureRefBound: () => void; private captureRefBound: () => void;
private onChangeBound: () => void;
private onSaveBound: () => void;
private inputRef: React.Ref<HTMLInputElement> | null; private inputRef: React.Ref<HTMLInputElement> | null;
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
const { caption } = props;
this.state = {
caption: caption || '',
};
this.handleKeyUpBound = this.handleKeyUp.bind(this); this.handleKeyUpBound = this.handleKeyUp.bind(this);
this.setFocusBound = this.setFocus.bind(this); this.setFocusBound = this.setFocus.bind(this);
this.captureRefBound = this.captureRef.bind(this); this.captureRefBound = this.captureRef.bind(this);
this.onChangeBound = this.onChange.bind(this);
this.onSaveBound = this.onSave.bind(this);
this.inputRef = null; this.inputRef = null;
} }
public handleKeyUp(event: React.KeyboardEvent<HTMLInputElement>) { public handleKeyUp(event: React.KeyboardEvent<HTMLInputElement>) {
const { close } = this.props; const { close, onSave } = this.props;
if (close && (event.key === 'Escape' || event.key === 'Enter')) { if (close && event.key === 'Escape') {
close(); close();
} }
if (onSave && event.key === 'Enter') {
const { caption } = this.state;
onSave(caption);
}
} }
public setFocus() { public setFocus() {
@ -55,6 +76,24 @@ export class CaptionEditor extends React.Component<Props> {
}, 200); }, 200);
} }
public onSave() {
const { onSave } = this.props;
const { caption } = this.state;
if (onSave) {
onSave(caption);
}
}
public onChange(event: React.FormEvent<HTMLInputElement>) {
// @ts-ignore
const { value } = event.target;
this.setState({
caption: value,
});
}
public renderObject() { public renderObject() {
const { url, i18n, attachment } = this.props; const { url, i18n, attachment } = this.props;
const { contentType } = attachment || { contentType: null }; const { contentType } = attachment || { contentType: null };
@ -83,7 +122,8 @@ export class CaptionEditor extends React.Component<Props> {
} }
public render() { public render() {
const { caption, i18n, close, onChangeCaption } = this.props; const { i18n, close } = this.props;
const { caption } = this.state;
return ( return (
<div <div
@ -100,21 +140,27 @@ export class CaptionEditor extends React.Component<Props> {
{this.renderObject()} {this.renderObject()}
</div> </div>
<div className="module-caption-editor__bottom-bar"> <div className="module-caption-editor__bottom-bar">
<div className="module-caption-editor__add-caption-button" /> <div className="module-caption-editor__input-container">
<input <input
type="text" type="text"
ref={this.captureRefBound} ref={this.captureRefBound}
onKeyUp={close ? this.handleKeyUpBound : undefined} value={caption}
value={caption || ''} maxLength={200}
maxLength={200} placeholder={i18n('addACaption')}
placeholder={i18n('addACaption')} className="module-caption-editor__caption-input"
className="module-caption-editor__caption-input" onKeyUp={close ? this.handleKeyUpBound : undefined}
onChange={event => { onChange={this.onChangeBound}
if (onChangeCaption) { />
onChangeCaption(event.target.value); {caption ? (
} <div
}} role="button"
/> onClick={this.onSaveBound}
className="module-caption-editor__save-button"
>
{i18n('save')}
</div>
) : null}
</div>
</div> </div>
</div> </div>
); );

View file

@ -19,8 +19,9 @@ const attachments = [
onCloseAttachment={attachment => { onCloseAttachment={attachment => {
console.log('onCloseAttachment', attachment); console.log('onCloseAttachment', attachment);
}} }}
onAddAttachment={() => console.log('onAddAttachment')}
i18n={util.i18n} i18n={util.i18n}
/>; />
</util.ConversationContext>; </util.ConversationContext>;
``` ```
@ -64,6 +65,7 @@ const attachments = [
onCloseAttachment={attachment => { onCloseAttachment={attachment => {
console.log('onCloseAttachment', attachment); console.log('onCloseAttachment', attachment);
}} }}
onAddAttachment={() => console.log('onAddAttachment')}
i18n={util.i18n} i18n={util.i18n}
/> />
</util.ConversationContext>; </util.ConversationContext>;
@ -101,6 +103,7 @@ const attachments = [
onCloseAttachment={attachment => { onCloseAttachment={attachment => {
console.log('onCloseAttachment', attachment); console.log('onCloseAttachment', attachment);
}} }}
onAddAttachment={() => console.log('onAddAttachment')}
i18n={util.i18n} i18n={util.i18n}
/> />
</util.ConversationContext>; </util.ConversationContext>;

View file

@ -6,7 +6,9 @@ import {
} from '../../util/GoogleChrome'; } from '../../util/GoogleChrome';
import { AttachmentType } from './types'; import { AttachmentType } from './types';
import { Image } from './Image'; import { Image } from './Image';
import { areAllAttachmentsVisual } from './ImageGrid';
import { StagedGenericAttachment } from './StagedGenericAttachment'; import { StagedGenericAttachment } from './StagedGenericAttachment';
import { StagedPlaceholderAttachment } from './StagedPlaceholderAttachment';
import { Localizer } from '../../types/Util'; import { Localizer } from '../../types/Util';
interface Props { interface Props {
@ -15,6 +17,7 @@ interface Props {
// onError: () => void; // onError: () => void;
onClickAttachment: (attachment: AttachmentType) => void; onClickAttachment: (attachment: AttachmentType) => void;
onCloseAttachment: (attachment: AttachmentType) => void; onCloseAttachment: (attachment: AttachmentType) => void;
onAddAttachment: () => void;
onClose: () => void; onClose: () => void;
} }
@ -27,6 +30,7 @@ export class AttachmentList extends React.Component<Props> {
const { const {
attachments, attachments,
i18n, i18n,
onAddAttachment,
onClickAttachment, onClickAttachment,
onCloseAttachment, onCloseAttachment,
onClose, onClose,
@ -36,6 +40,8 @@ export class AttachmentList extends React.Component<Props> {
return null; return null;
} }
const allVisualAttachments = areAllAttachmentsVisual(attachments);
return ( return (
<div className="module-attachments"> <div className="module-attachments">
{attachments.length > 1 ? ( {attachments.length > 1 ? (
@ -85,6 +91,9 @@ export class AttachmentList extends React.Component<Props> {
/> />
); );
})} })}
{allVisualAttachments ? (
<StagedPlaceholderAttachment onClick={onAddAttachment} />
) : null}
</div> </div>
</div> </div>
); );

View file

@ -389,7 +389,9 @@ function getImageDimensions(attachment: AttachmentType): DimensionsType {
}; };
} }
function areAllAttachmentsVisual(attachments?: Array<AttachmentType>): boolean { export function areAllAttachmentsVisual(
attachments?: Array<AttachmentType>
): boolean {
if (!attachments) { if (!attachments) {
return false; return false;
} }
@ -397,7 +399,7 @@ function areAllAttachmentsVisual(attachments?: Array<AttachmentType>): boolean {
const max = attachments.length; const max = attachments.length;
for (let i = 0; i < max; i += 1) { for (let i = 0; i < max; i += 1) {
const attachment = attachments[i]; const attachment = attachments[i];
if (!isImageAttachment(attachment) || !isVideoAttachment(attachment)) { if (!isImageAttachment(attachment) && !isVideoAttachment(attachment)) {
return false; return false;
} }
} }

View file

@ -0,0 +1,10 @@
```js
const attachment = {
contentType: 'text/plain',
fileName: 'manifesto.txt',
};
<util.ConversationContext theme={util.theme}>
<StagedPlaceholderAttachment onClick={attachment => console.log('onClick')} />
</util.ConversationContext>;
```

View file

@ -0,0 +1,21 @@
import React from 'react';
interface Props {
onClick: () => void;
}
export class StagedPlaceholderAttachment extends React.Component<Props> {
public render() {
const { onClick } = this.props;
return (
<div
className="module-staged-placeholder-attachment"
role="button"
onClick={onClick}
>
<div className="module-staged-placeholder-attachment__plus-icon" />
</div>
);
}
}