Doodlebox
A modification to the Mastodon interface that allows you to select a doodle icon in addition to uploading a media file from your computer. The doodle interface allows you to draw using your mouse, finger, or pointing device in several colors. It was originally developed by Ondřej Hruška <ondra@ondrovo.com> for use on the GlitchSoc fork.
This commit is contained in:
		
							parent
							
								
									31e7940de5
								
							
						
					
					
						commit
						ef859e16e8
					
				
					 12 changed files with 1046 additions and 8 deletions
				
			
		|  | @ -49,6 +49,8 @@ export const COMPOSE_UPLOAD_CHANGE_REQUEST     = 'COMPOSE_UPLOAD_UPDATE_REQUEST' | |||
| export const COMPOSE_UPLOAD_CHANGE_SUCCESS     = 'COMPOSE_UPLOAD_UPDATE_SUCCESS'; | ||||
| export const COMPOSE_UPLOAD_CHANGE_FAIL        = 'COMPOSE_UPLOAD_UPDATE_FAIL'; | ||||
| 
 | ||||
| export const COMPOSE_DOODLE_SET        = 'COMPOSE_DOODLE_SET'; | ||||
| 
 | ||||
| export function changeCompose(text) { | ||||
|   return { | ||||
|     type: COMPOSE_CHANGE, | ||||
|  | @ -182,6 +184,13 @@ export function submitComposeFail(error) { | |||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export function doodleSet(options) { | ||||
|   return { | ||||
|     type: COMPOSE_DOODLE_SET, | ||||
|     options: options, | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export function uploadCompose(files) { | ||||
|   return function (dispatch, getState) { | ||||
|     if (getState().getIn(['compose', 'media_attachments']).size > 3) { | ||||
|  |  | |||
|  | @ -22,6 +22,7 @@ export default class IconButton extends React.PureComponent { | |||
|     animate: PropTypes.bool, | ||||
|     overlay: PropTypes.bool, | ||||
|     tabIndex: PropTypes.string, | ||||
|     label: PropTypes.string, | ||||
|   }; | ||||
| 
 | ||||
|   static defaultProps = { | ||||
|  | @ -42,14 +43,18 @@ export default class IconButton extends React.PureComponent { | |||
|   } | ||||
| 
 | ||||
|   render () { | ||||
|     const style = { | ||||
|     let style = { | ||||
|       fontSize: `${this.props.size}px`, | ||||
|       width: `${this.props.size * 1.28571429}px`, | ||||
|       height: `${this.props.size * 1.28571429}px`, | ||||
|       lineHeight: `${this.props.size}px`, | ||||
|       ...this.props.style, | ||||
|       ...(this.props.active ? this.props.activeStyle : {}), | ||||
|     }; | ||||
|     if (!this.props.label) { | ||||
|       style.width = `${this.props.size * 1.28571429}px`; | ||||
|     } else { | ||||
|       style.textAlign = 'left'; | ||||
|     } | ||||
| 
 | ||||
|     const { | ||||
|       active, | ||||
|  | @ -104,7 +109,8 @@ export default class IconButton extends React.PureComponent { | |||
|             style={style} | ||||
|             tabIndex={tabIndex} | ||||
|           > | ||||
|             <i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${icon}`} aria-hidden='true' /> | ||||
|             <i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' /> | ||||
|             {this.props.label} | ||||
|           </button> | ||||
|         )} | ||||
|       </Motion> | ||||
|  |  | |||
|  | @ -0,0 +1,133 @@ | |||
| //  Package imports  // 
 | ||||
| import React from 'react';  | ||||
| import PropTypes from 'prop-types';  | ||||
| import { connect } from 'react-redux';  | ||||
| import { injectIntl, defineMessages } from 'react-intl';  | ||||
|   | ||||
| //  Our imports  // 
 | ||||
| import ComposeDropdown from './compose_dropdown';  | ||||
| import { uploadCompose } from '../../../actions/compose';  | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes';  | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component';  | ||||
| import { openModal } from '../../../actions/modal';  | ||||
|   | ||||
| //  * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * 
 | ||||
|   | ||||
| const messages = defineMessages({  | ||||
|   upload :  | ||||
|     { id: 'compose.attach.upload', defaultMessage: 'Upload a file' },  | ||||
|   doodle :  | ||||
|     { id: 'compose.attach.doodle', defaultMessage: 'Draw something' },  | ||||
|   attach :  | ||||
|     { id: 'compose.attach', defaultMessage: 'Attach...' },  | ||||
| });  | ||||
|   | ||||
| const mapStateToProps = state => ({  | ||||
|   // This horrible expression is copied from vanilla upload_button_container 
 | ||||
|   disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')),  | ||||
|   resetFileKey: state.getIn(['compose', 'resetFileKey']),  | ||||
|   acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),  | ||||
| });  | ||||
|   | ||||
| const mapDispatchToProps = dispatch => ({  | ||||
|   onSelectFile (files) {  | ||||
|     dispatch(uploadCompose(files));  | ||||
|   },  | ||||
|   onOpenDoodle () {  | ||||
|     dispatch(openModal('DOODLE', { noEsc: true }));  | ||||
|   },  | ||||
| });  | ||||
|   | ||||
| @injectIntl  | ||||
| @connect(mapStateToProps, mapDispatchToProps)  | ||||
| export default class ComposeAttachOptions extends ImmutablePureComponent {  | ||||
|   | ||||
|   static propTypes = {  | ||||
|     intl     : PropTypes.object.isRequired,  | ||||
|     resetFileKey: PropTypes.number,  | ||||
|     acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired,  | ||||
|     disabled: PropTypes.bool,  | ||||
|     onSelectFile: PropTypes.func.isRequired,  | ||||
|     onOpenDoodle: PropTypes.func.isRequired,  | ||||
|   };  | ||||
|   | ||||
|   handleItemClick = bt => {  | ||||
|     if (bt === 'upload') {  | ||||
|       this.fileElement.click();  | ||||
|     }  | ||||
|   | ||||
|     if (bt === 'doodle') {  | ||||
|       this.props.onOpenDoodle();  | ||||
|     }  | ||||
|   | ||||
|     this.dropdown.setState({ open: false });  | ||||
|   };  | ||||
|   | ||||
|   handleFileChange = (e) => {  | ||||
|     if (e.target.files.length > 0) {  | ||||
|       this.props.onSelectFile(e.target.files);  | ||||
|     }  | ||||
|   }  | ||||
|   | ||||
|   setFileRef = (c) => {  | ||||
|     this.fileElement = c;  | ||||
|   }  | ||||
|   | ||||
|   setDropdownRef = (c) => {  | ||||
|     this.dropdown = c;  | ||||
|   }  | ||||
|   | ||||
|   render () {  | ||||
|     const { intl, resetFileKey, disabled, acceptContentTypes } = this.props;  | ||||
|   | ||||
|     const options = [  | ||||
|       { icon: 'cloud-upload', text: messages.upload, name: 'upload' },  | ||||
|       { icon: 'paint-brush', text: messages.doodle, name: 'doodle' },  | ||||
|     ];  | ||||
|   | ||||
|     const optionElems = options.map((item) => {  | ||||
|       const hdl = () => this.handleItemClick(item.name);  | ||||
|       return (  | ||||
|         <div  | ||||
|           role='button'  | ||||
|           tabIndex='0'  | ||||
|           key={item.name}  | ||||
|           onClick={hdl}  | ||||
|           className='privacy-dropdown__option'  | ||||
|         >  | ||||
|           <div className='privacy-dropdown__option__icon'>  | ||||
|             <i className={`fa fa-fw fa-${item.icon}`} />  | ||||
|           </div>  | ||||
|   | ||||
|           <div className='privacy-dropdown__option__content'>  | ||||
|             <strong>{intl.formatMessage(item.text)}</strong>  | ||||
|           </div>  | ||||
|         </div>  | ||||
|       );  | ||||
|     });  | ||||
|   | ||||
|     return (  | ||||
|       <div>  | ||||
|         <ComposeDropdown  | ||||
|           title={intl.formatMessage(messages.attach)}  | ||||
|           icon='paperclip'  | ||||
|           disabled={disabled}  | ||||
|           ref={this.setDropdownRef}  | ||||
|         >  | ||||
|           {optionElems}  | ||||
|         </ComposeDropdown>  | ||||
|         <input  | ||||
|           key={resetFileKey}  | ||||
|           ref={this.setFileRef}  | ||||
|           type='file'  | ||||
|           multiple={false}  | ||||
|           accept={acceptContentTypes.toArray().join(',')}  | ||||
|           onChange={this.handleFileChange}  | ||||
|           disabled={disabled}  | ||||
|           style={{ display: 'none' }}  | ||||
|         />  | ||||
|       </div>  | ||||
|     );  | ||||
|   }  | ||||
|   | ||||
| }  | ||||
|  | @ -0,0 +1,76 @@ | |||
| //  Package imports  // 
 | ||||
| import React from 'react';  | ||||
| import PropTypes from 'prop-types';  | ||||
|   | ||||
| //  Mastodon imports  // 
 | ||||
| import IconButton from '../../../components/icon_button';  | ||||
|   | ||||
| const iconStyle = {  | ||||
|   height     : null,  | ||||
|   lineHeight : '27px',  | ||||
| };  | ||||
|   | ||||
| export default class ComposeDropdown extends React.PureComponent { | ||||
|   | ||||
|   static propTypes = {  | ||||
|     title: PropTypes.string.isRequired,  | ||||
|     icon: PropTypes.string,  | ||||
|     highlight: PropTypes.bool,  | ||||
|     disabled: PropTypes.bool,  | ||||
|     children: PropTypes.arrayOf(PropTypes.node).isRequired,  | ||||
|   };  | ||||
|   | ||||
|   state = {  | ||||
|     open: false,  | ||||
|   };  | ||||
|   | ||||
|   onGlobalClick = (e) => {  | ||||
|     if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) {  | ||||
|       this.setState({ open: false });  | ||||
|     }  | ||||
|   };  | ||||
|   | ||||
|   componentDidMount () {  | ||||
|     window.addEventListener('click', this.onGlobalClick);  | ||||
|     window.addEventListener('touchstart', this.onGlobalClick);  | ||||
|   }  | ||||
|   componentWillUnmount () {  | ||||
|     window.removeEventListener('click', this.onGlobalClick);  | ||||
|     window.removeEventListener('touchstart', this.onGlobalClick);  | ||||
|   }  | ||||
|   | ||||
|   onToggleDropdown = () => {  | ||||
|     if (this.props.disabled) return;  | ||||
|     this.setState({ open: !this.state.open });  | ||||
|   };  | ||||
|   | ||||
|   setRef = (c) => {  | ||||
|     this.node = c;  | ||||
|   };  | ||||
|   | ||||
|   render () {  | ||||
|     const { open } = this.state;  | ||||
|     let { highlight, title, icon, disabled } = this.props;  | ||||
|   | ||||
|     if (!icon) icon = 'ellipsis-h';  | ||||
|   | ||||
|     return (  | ||||
|       <div ref={this.setRef} className={`advanced-options-dropdown ${open ?  'open' : ''} ${highlight ? 'active' : ''} `}>  | ||||
|         <div className='advanced-options-dropdown__value'>  | ||||
|           <IconButton  | ||||
|             className={'inverted'}  | ||||
|             title={title}  | ||||
|             icon={icon} active={open || highlight}  | ||||
|             size={18}  | ||||
|             style={iconStyle}  | ||||
|             disabled={disabled}  | ||||
|             onClick={this.onToggleDropdown}  | ||||
|           />  | ||||
|         </div>  | ||||
|         <div className='advanced-options-dropdown__dropdown'>  | ||||
|           {this.props.children}  | ||||
|         </div>  | ||||
|       </div>  | ||||
|     );  | ||||
|   } | ||||
| } | ||||
|  | @ -5,7 +5,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
| import PropTypes from 'prop-types'; | ||||
| import ReplyIndicatorContainer from '../containers/reply_indicator_container'; | ||||
| import AutosuggestTextarea from '../../../components/autosuggest_textarea'; | ||||
| import UploadButtonContainer from '../containers/upload_button_container'; | ||||
| import { defineMessages, injectIntl } from 'react-intl'; | ||||
| import SpoilerButtonContainer from '../containers/spoiler_button_container'; | ||||
| import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; | ||||
|  | @ -17,6 +16,7 @@ import { isMobile } from '../../../is_mobile'; | |||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| import { length } from 'stringz'; | ||||
| import { countableText } from '../util/counter'; | ||||
| import ComposeAttachOptions from './attach_options'; | ||||
| 
 | ||||
| const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d'; | ||||
| 
 | ||||
|  | @ -208,9 +208,10 @@ class ComposeForm extends ImmutablePureComponent { | |||
| 
 | ||||
|         <div className='compose-form__buttons-wrapper'> | ||||
|           <div className='compose-form__buttons'> | ||||
|             <UploadButtonContainer /> | ||||
|             <PrivacyDropdownContainer /> | ||||
|             <ComposeAttachOptions /> | ||||
|             <SensitiveButtonContainer /> | ||||
|             <div className='compose-form__buttons-separator' />  | ||||
|             <PrivacyDropdownContainer /> | ||||
|             <SpoilerButtonContainer /> | ||||
|           </div> | ||||
|           <div className='character-counter__wrapper'><CharacterCounter max={500} text={text} /></div> | ||||
|  |  | |||
							
								
								
									
										614
									
								
								app/javascript/mastodon/features/ui/components/doodle_modal.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										614
									
								
								app/javascript/mastodon/features/ui/components/doodle_modal.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,614 @@ | |||
| import React from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import Button from '../../../components/button'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| import Atrament from 'atrament'; // the doodling library
 | ||||
| import { connect } from 'react-redux'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import { doodleSet, uploadCompose } from '../../../actions/compose'; | ||||
| import IconButton from '../../../components/icon_button'; | ||||
| import { debounce, mapValues } from 'lodash'; | ||||
| import classNames from 'classnames'; | ||||
| 
 | ||||
| // palette nicked from MyPaint, CC0
 | ||||
| const palette = [ | ||||
|   ['rgb(  0,    0,    0)', 'Black'], | ||||
|   ['rgb( 38,   38,   38)', 'Gray 15'], | ||||
|   ['rgb( 77,   77,   77)', 'Grey 30'], | ||||
|   ['rgb(128,  128,  128)', 'Grey 50'], | ||||
|   ['rgb(171,  171,  171)', 'Grey 67'], | ||||
|   ['rgb(217,  217,  217)', 'Grey 85'], | ||||
|   ['rgb(255,  255,  255)', 'White'], | ||||
|   ['rgb(128,    0,    0)', 'Maroon'], | ||||
|   ['rgb(209,    0,    0)', 'English-red'], | ||||
|   ['rgb(255,   54,   34)', 'Tomato'], | ||||
|   ['rgb(252,   60,    3)', 'Orange-red'], | ||||
|   ['rgb(255,  140,  105)', 'Salmon'], | ||||
|   ['rgb(252,  232,   32)', 'Cadium-yellow'], | ||||
|   ['rgb(243,  253,   37)', 'Lemon yellow'], | ||||
|   ['rgb(121,    5,   35)', 'Dark crimson'], | ||||
|   ['rgb(169,   32,   62)', 'Deep carmine'], | ||||
|   ['rgb(255,  140,    0)', 'Orange'], | ||||
|   ['rgb(255,  168,   18)', 'Dark tangerine'], | ||||
|   ['rgb(217,  144,   88)', 'Persian orange'], | ||||
|   ['rgb(194,  178,  128)', 'Sand'], | ||||
|   ['rgb(255,  229,  180)', 'Peach'], | ||||
|   ['rgb(100,   54,   46)', 'Bole'], | ||||
|   ['rgb(108,   41,   52)', 'Dark cordovan'], | ||||
|   ['rgb(163,   65,   44)', 'Chestnut'], | ||||
|   ['rgb(228,  136,  100)', 'Dark salmon'], | ||||
|   ['rgb(255,  195,  143)', 'Apricot'], | ||||
|   ['rgb(255,  219,  188)', 'Unbleached silk'], | ||||
|   ['rgb(242,  227,  198)', 'Straw'], | ||||
|   ['rgb( 53,   19,   13)', 'Bistre'], | ||||
|   ['rgb( 84,   42,   14)', 'Dark chocolate'], | ||||
|   ['rgb(102,   51,   43)', 'Burnt sienna'], | ||||
|   ['rgb(184,   66,    0)', 'Sienna'], | ||||
|   ['rgb(216,  153,   12)', 'Yellow ochre'], | ||||
|   ['rgb(210,  180,  140)', 'Tan'], | ||||
|   ['rgb(232,  204,  144)', 'Dark wheat'], | ||||
|   ['rgb(  0,   49,   83)', 'Prussian blue'], | ||||
|   ['rgb( 48,   69,  119)', 'Dark grey blue'], | ||||
|   ['rgb(  0,   71,  171)', 'Cobalt blue'], | ||||
|   ['rgb( 31,  117,  254)', 'Blue'], | ||||
|   ['rgb(120,  180,  255)', 'Bright french blue'], | ||||
|   ['rgb(171,  200,  255)', 'Bright steel blue'], | ||||
|   ['rgb(208,  231,  255)', 'Ice blue'], | ||||
|   ['rgb( 30,   51,   58)', 'Medium jungle green'], | ||||
|   ['rgb( 47,   79,   79)', 'Dark slate grey'], | ||||
|   ['rgb( 74,  104,   93)', 'Dark grullo green'], | ||||
|   ['rgb(  0,  128,  128)', 'Teal'], | ||||
|   ['rgb( 67,  170,  176)', 'Turquoise'], | ||||
|   ['rgb(109,  174,  199)', 'Cerulean frost'], | ||||
|   ['rgb(173,  217,  186)', 'Tiffany green'], | ||||
|   ['rgb( 22,   34,   29)', 'Gray-asparagus'], | ||||
|   ['rgb( 36,   48,   45)', 'Medium dark teal'], | ||||
|   ['rgb( 74,  104,   93)', 'Xanadu'], | ||||
|   ['rgb(119,  198,  121)', 'Mint'], | ||||
|   ['rgb(175,  205,  182)', 'Timberwolf'], | ||||
|   ['rgb(185,  245,  246)', 'Celeste'], | ||||
|   ['rgb(193,  255,  234)', 'Aquamarine'], | ||||
|   ['rgb( 29,   52,   35)', 'Cal Poly Pomona'], | ||||
|   ['rgb(  1,   68,   33)', 'Forest green'], | ||||
|   ['rgb( 42,  128,    0)', 'Napier green'], | ||||
|   ['rgb(128,  128,    0)', 'Olive'], | ||||
|   ['rgb( 65,  156,  105)', 'Sea green'], | ||||
|   ['rgb(189,  246,   29)', 'Green-yellow'], | ||||
|   ['rgb(231,  244,  134)', 'Bright chartreuse'], | ||||
|   ['rgb(138,   23,  137)', 'Purple'], | ||||
|   ['rgb( 78,   39,  138)', 'Violet'], | ||||
|   ['rgb(193,   75,  110)', 'Dark thulian pink'], | ||||
|   ['rgb(222,   49,   99)', 'Cerise'], | ||||
|   ['rgb(255,   20,  147)', 'Deep pink'], | ||||
|   ['rgb(255,  102,  204)', 'Rose pink'], | ||||
|   ['rgb(255,  203,  219)', 'Pink'], | ||||
|   ['rgb(255,  255,  255)', 'White'], | ||||
|   ['rgb(229,   17,    1)', 'RGB Red'], | ||||
|   ['rgb(  0,  255,    0)', 'RGB Green'], | ||||
|   ['rgb(  0,    0,  255)', 'RGB Blue'], | ||||
|   ['rgb(  0,  255,  255)', 'CMYK Cyan'], | ||||
|   ['rgb(255,    0,  255)', 'CMYK Magenta'], | ||||
|   ['rgb(255,  255,    0)', 'CMYK Yellow'], | ||||
| ]; | ||||
| 
 | ||||
| // re-arrange to the right order for display
 | ||||
| let palReordered = []; | ||||
| for (let row = 0; row < 7; row++) { | ||||
|   for (let col = 0; col < 11; col++) { | ||||
|     palReordered.push(palette[col * 7 + row]); | ||||
|   } | ||||
|   palReordered.push(null); // null indicates a <br />
 | ||||
| } | ||||
| 
 | ||||
| // Utility for converting base64 image to binary for upload
 | ||||
| // https://stackoverflow.com/questions/35940290/how-to-convert-base64-string-to-javascript-file-object-like-as-from-file-input-f
 | ||||
| function dataURLtoFile(dataurl, filename) { | ||||
|   let arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1], | ||||
|     bstr = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n); | ||||
|   while(n--){ | ||||
|     u8arr[n] = bstr.charCodeAt(n); | ||||
|   } | ||||
|   return new File([u8arr], filename, { type: mime }); | ||||
| } | ||||
| 
 | ||||
| const DOODLE_SIZES = { | ||||
|   normal: [500, 500, 'Square 500'], | ||||
|   tootbanner: [702, 330, 'Tootbanner'], | ||||
|   s640x480: [640, 480, '640×480 - 480p'], | ||||
|   s800x600: [800, 600, '800×600 - SVGA'], | ||||
|   s720x480: [720, 405, '720x405 - 16:9'], | ||||
| }; | ||||
| 
 | ||||
| 
 | ||||
| const mapStateToProps = state => ({ | ||||
|   options: state.getIn(['compose', 'doodle']), | ||||
| }); | ||||
| 
 | ||||
| const mapDispatchToProps = dispatch => ({ | ||||
|   /** Set options in the redux store */ | ||||
|   setOpt: (opts) => dispatch(doodleSet(opts)), | ||||
|   /** Submit doodle for upload */ | ||||
|   submit: (file) => dispatch(uploadCompose([file])), | ||||
| }); | ||||
| 
 | ||||
| /** | ||||
|  * Doodling dialog with drawing canvas | ||||
|  * | ||||
|  * Keyboard shortcuts: | ||||
|  * - Delete: Clear screen, fill with background color | ||||
|  * - Backspace, Ctrl+Z: Undo one step | ||||
|  * - Ctrl held while drawing: Use background color | ||||
|  * - Shift held while clicking screen: Use fill tool | ||||
|  * | ||||
|  * Palette: | ||||
|  * - Left mouse button: pick foreground | ||||
|  * - Ctrl + left mouse button: pick background | ||||
|  * - Right mouse button: pick background | ||||
|  */ | ||||
| @connect(mapStateToProps, mapDispatchToProps) | ||||
| export default class DoodleModal extends ImmutablePureComponent { | ||||
| 
 | ||||
|   static propTypes = { | ||||
|     options: ImmutablePropTypes.map, | ||||
|     onClose: PropTypes.func.isRequired, | ||||
|     setOpt: PropTypes.func.isRequired, | ||||
|     submit: PropTypes.func.isRequired, | ||||
|   }; | ||||
| 
 | ||||
|   //region Option getters/setters
 | ||||
| 
 | ||||
|   /** Foreground color */ | ||||
|   get fg () { | ||||
|     return this.props.options.get('fg'); | ||||
|   } | ||||
|   set fg (value) { | ||||
|     this.props.setOpt({ fg: value }); | ||||
|   } | ||||
| 
 | ||||
|   /** Background color */ | ||||
|   get bg () { | ||||
|     return this.props.options.get('bg'); | ||||
|   } | ||||
|   set bg (value) { | ||||
|     this.props.setOpt({ bg: value }); | ||||
|   } | ||||
| 
 | ||||
|   /** Swap Fg and Bg for drawing */ | ||||
|   get swapped () { | ||||
|     return this.props.options.get('swapped'); | ||||
|   } | ||||
|   set swapped (value) { | ||||
|     this.props.setOpt({ swapped: value }); | ||||
|   } | ||||
| 
 | ||||
|   /** Mode - 'draw' or 'fill' */ | ||||
|   get mode () { | ||||
|     return this.props.options.get('mode'); | ||||
|   } | ||||
|   set mode (value) { | ||||
|     this.props.setOpt({ mode: value }); | ||||
|   } | ||||
| 
 | ||||
|   /** Base line weight */ | ||||
|   get weight () { | ||||
|     return this.props.options.get('weight'); | ||||
|   } | ||||
|   set weight (value) { | ||||
|     this.props.setOpt({ weight: value }); | ||||
|   } | ||||
| 
 | ||||
|   /** Drawing opacity */ | ||||
|   get opacity () { | ||||
|     return this.props.options.get('opacity'); | ||||
|   } | ||||
|   set opacity (value) { | ||||
|     this.props.setOpt({ opacity: value }); | ||||
|   } | ||||
| 
 | ||||
|   /** Adaptive stroke - change width with speed */ | ||||
|   get adaptiveStroke () { | ||||
|     return this.props.options.get('adaptiveStroke'); | ||||
|   } | ||||
|   set adaptiveStroke (value) { | ||||
|     this.props.setOpt({ adaptiveStroke: value }); | ||||
|   } | ||||
| 
 | ||||
|   /** Smoothing (for mouse drawing) */ | ||||
|   get smoothing () { | ||||
|     return this.props.options.get('smoothing'); | ||||
|   } | ||||
|   set smoothing (value) { | ||||
|     this.props.setOpt({ smoothing: value }); | ||||
|   } | ||||
| 
 | ||||
|   /** Size preset */ | ||||
|   get size () { | ||||
|     return this.props.options.get('size'); | ||||
|   } | ||||
|   set size (value) { | ||||
|     this.props.setOpt({ size: value }); | ||||
|   } | ||||
| 
 | ||||
|   //endregion
 | ||||
| 
 | ||||
|   /** Key up handler */ | ||||
|   handleKeyUp = (e) => { | ||||
|     if (e.target.nodeName === 'INPUT') return; | ||||
| 
 | ||||
|     if (e.key === 'Delete') { | ||||
|       e.preventDefault(); | ||||
|       this.handleClearBtn(); | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     if (e.key === 'Backspace' || (e.key === 'z' && (e.ctrlKey || e.metaKey))) { | ||||
|       e.preventDefault(); | ||||
|       this.undo(); | ||||
|     } | ||||
| 
 | ||||
|     if (e.key === 'Control' || e.key === 'Meta') { | ||||
|       this.controlHeld = false; | ||||
|       this.swapped = false; | ||||
|     } | ||||
| 
 | ||||
|     if (e.key === 'Shift') { | ||||
|       this.shiftHeld = false; | ||||
|       this.mode = 'draw'; | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   /** Key down handler */ | ||||
|   handleKeyDown = (e) => { | ||||
|     if (e.key === 'Control' || e.key === 'Meta') { | ||||
|       this.controlHeld = true; | ||||
|       this.swapped = true; | ||||
|     } | ||||
| 
 | ||||
|     if (e.key === 'Shift') { | ||||
|       this.shiftHeld = true; | ||||
|       this.mode = 'fill'; | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   /** | ||||
|    * Component installed in the DOM, do some initial set-up | ||||
|    */ | ||||
|   componentDidMount () { | ||||
|     this.controlHeld = false; | ||||
|     this.shiftHeld = false; | ||||
|     this.swapped = false; | ||||
|     window.addEventListener('keyup', this.handleKeyUp, false); | ||||
|     window.addEventListener('keydown', this.handleKeyDown, false); | ||||
|   }; | ||||
| 
 | ||||
|   /** | ||||
|    * Tear component down | ||||
|    */ | ||||
|   componentWillUnmount () { | ||||
|     window.removeEventListener('keyup', this.handleKeyUp, false); | ||||
|     window.removeEventListener('keydown', this.handleKeyDown, false); | ||||
|     if (this.sketcher) this.sketcher.destroy(); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Set reference to the canvas element. | ||||
|    * This is called during component init | ||||
|    * | ||||
|    * @param elem - canvas element | ||||
|    */ | ||||
|   setCanvasRef = (elem) => { | ||||
|     this.canvas = elem; | ||||
|     if (elem) { | ||||
|       elem.addEventListener('dirty', () => { | ||||
|         this.saveUndo(); | ||||
|         this.sketcher._dirty = false; | ||||
|       }); | ||||
| 
 | ||||
|       elem.addEventListener('click', () => { | ||||
|         // sketcher bug - does not fire dirty on fill
 | ||||
|         if (this.mode === 'fill') { | ||||
|           this.saveUndo(); | ||||
|         } | ||||
|       }); | ||||
| 
 | ||||
|       // prevent context menu
 | ||||
|       elem.addEventListener('contextmenu', (e) => { | ||||
|         e.preventDefault(); | ||||
|       }); | ||||
| 
 | ||||
|       elem.addEventListener('mousedown', (e) => { | ||||
|         if (e.button === 2) { | ||||
|           this.swapped = true; | ||||
|         } | ||||
|       }); | ||||
| 
 | ||||
|       elem.addEventListener('mouseup', (e) => { | ||||
|         if (e.button === 2) { | ||||
|           this.swapped = this.controlHeld; | ||||
|         } | ||||
|       }); | ||||
| 
 | ||||
|       this.initSketcher(elem); | ||||
|       this.mode = 'draw'; // Reset mode - it's confusing if left at 'fill'
 | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   /** | ||||
|    * Set up the sketcher instance | ||||
|    * | ||||
|    * @param canvas - canvas element. Null if we're just resizing | ||||
|    */ | ||||
|   initSketcher (canvas = null) { | ||||
|     const sizepreset = DOODLE_SIZES[this.size]; | ||||
| 
 | ||||
|     if (this.sketcher) this.sketcher.destroy(); | ||||
|     this.sketcher = new Atrament(canvas || this.canvas, sizepreset[0], sizepreset[1]); | ||||
| 
 | ||||
|     if (canvas) { | ||||
|       this.ctx = this.sketcher.context; | ||||
|       this.updateSketcherSettings(); | ||||
|     } | ||||
| 
 | ||||
|     this.clearScreen(); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Done button handler | ||||
|    */ | ||||
|   onDoneButton = () => { | ||||
|     const dataUrl = this.sketcher.toImage(); | ||||
|     const file = dataURLtoFile(dataUrl, 'doodle.png'); | ||||
|     this.props.submit(file); | ||||
|     this.props.onClose(); // close dialog
 | ||||
|   }; | ||||
| 
 | ||||
|   /** | ||||
|    * Cancel button handler | ||||
|    */ | ||||
|   onCancelButton = () => { | ||||
|     if (this.undos.length > 1 && !confirm('Discard doodle? All changes will be lost!')) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     this.props.onClose(); // close dialog
 | ||||
|   }; | ||||
| 
 | ||||
|   /** | ||||
|    * Update sketcher options based on state | ||||
|    */ | ||||
|   updateSketcherSettings () { | ||||
|     if (!this.sketcher) return; | ||||
| 
 | ||||
|     if (this.oldSize !== this.size) this.initSketcher(); | ||||
| 
 | ||||
|     this.sketcher.color = (this.swapped ? this.bg : this.fg); | ||||
|     this.sketcher.opacity = this.opacity; | ||||
|     this.sketcher.weight = this.weight; | ||||
|     this.sketcher.mode = this.mode; | ||||
|     this.sketcher.smoothing = this.smoothing; | ||||
|     this.sketcher.adaptiveStroke = this.adaptiveStroke; | ||||
| 
 | ||||
|     this.oldSize = this.size; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Fill screen with background color | ||||
|    */ | ||||
|   clearScreen = () => { | ||||
|     this.ctx.fillStyle = this.bg; | ||||
|     this.ctx.fillRect(-1, -1, this.canvas.width+2, this.canvas.height+2); | ||||
|     this.undos = []; | ||||
| 
 | ||||
|     this.doSaveUndo(); | ||||
|   }; | ||||
| 
 | ||||
|   /** | ||||
|    * Undo one step | ||||
|    */ | ||||
|   undo = () => { | ||||
|     if (this.undos.length > 1) { | ||||
|       this.undos.pop(); | ||||
|       const buf = this.undos.pop(); | ||||
| 
 | ||||
|       this.sketcher.clear(); | ||||
|       this.ctx.putImageData(buf, 0, 0); | ||||
|       this.doSaveUndo(); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   /** | ||||
|    * Save canvas content into the undo buffer immediately | ||||
|    */ | ||||
|   doSaveUndo = () => { | ||||
|     this.undos.push(this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height)); | ||||
|   }; | ||||
| 
 | ||||
|   /** | ||||
|    * Called on each canvas change. | ||||
|    * Saves canvas content to the undo buffer after some period of inactivity. | ||||
|    */ | ||||
|   saveUndo = debounce(() => { | ||||
|     this.doSaveUndo(); | ||||
|   }, 100); | ||||
| 
 | ||||
|   /** | ||||
|    * Palette left click. | ||||
|    * Selects Fg color (or Bg, if Control/Meta is held) | ||||
|    * | ||||
|    * @param e - event | ||||
|    */ | ||||
|   onPaletteClick = (e) => { | ||||
|     const c = e.target.dataset.color; | ||||
| 
 | ||||
|     if (this.controlHeld) { | ||||
|       this.bg = c; | ||||
|     } else { | ||||
|       this.fg = c; | ||||
|     } | ||||
| 
 | ||||
|     e.target.blur(); | ||||
|     e.preventDefault(); | ||||
|   }; | ||||
| 
 | ||||
|   /** | ||||
|    * Palette right click. | ||||
|    * Selects Bg color | ||||
|    * | ||||
|    * @param e - event | ||||
|    */ | ||||
|   onPaletteRClick = (e) => { | ||||
|     this.bg = e.target.dataset.color; | ||||
|     e.target.blur(); | ||||
|     e.preventDefault(); | ||||
|   }; | ||||
| 
 | ||||
|   /** | ||||
|    * Handle click on the Draw mode button | ||||
|    * | ||||
|    * @param e - event | ||||
|    */ | ||||
|   setModeDraw = (e) => { | ||||
|     this.mode = 'draw'; | ||||
|     e.target.blur(); | ||||
|   }; | ||||
| 
 | ||||
|   /** | ||||
|    * Handle click on the Fill mode button | ||||
|    * | ||||
|    * @param e - event | ||||
|    */ | ||||
|   setModeFill = (e) => { | ||||
|     this.mode = 'fill'; | ||||
|     e.target.blur(); | ||||
|   }; | ||||
| 
 | ||||
|   /** | ||||
|    * Handle click on Smooth checkbox | ||||
|    * | ||||
|    * @param e - event | ||||
|    */ | ||||
|   tglSmooth = (e) => { | ||||
|     this.smoothing = !this.smoothing; | ||||
|     e.target.blur(); | ||||
|   }; | ||||
| 
 | ||||
|   /** | ||||
|    * Handle click on Adaptive checkbox | ||||
|    * | ||||
|    * @param e - event | ||||
|    */ | ||||
|   tglAdaptive = (e) => { | ||||
|     this.adaptiveStroke = !this.adaptiveStroke; | ||||
|     e.target.blur(); | ||||
|   }; | ||||
| 
 | ||||
|   /** | ||||
|    * Handle change of the Weight input field | ||||
|    * | ||||
|    * @param e - event | ||||
|    */ | ||||
|   setWeight = (e) => { | ||||
|     this.weight = +e.target.value || 1; | ||||
|   }; | ||||
| 
 | ||||
|   /** | ||||
|    * Set size - clalback from the select box | ||||
|    * | ||||
|    * @param e - event | ||||
|    */ | ||||
|   changeSize = (e) => { | ||||
|     let newSize = e.target.value; | ||||
|     if (newSize === this.oldSize) return; | ||||
| 
 | ||||
|     if (this.undos.length > 1 && !confirm('Change size? This will erase your drawing!')) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     this.size = newSize; | ||||
|   }; | ||||
| 
 | ||||
|   handleClearBtn = () => { | ||||
|     if (this.undos.length > 1 && !confirm('Clear screen? This will erase your drawing!')) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     this.clearScreen(); | ||||
|   }; | ||||
| 
 | ||||
|   /** | ||||
|    * Render the component | ||||
|    */ | ||||
|   render () { | ||||
|     this.updateSketcherSettings(); | ||||
| 
 | ||||
|     return ( | ||||
|       <div className='modal-root__modal doodle-modal'> | ||||
|         <div className='doodle-modal__container'> | ||||
|           <canvas ref={this.setCanvasRef} /> | ||||
|         </div> | ||||
| 
 | ||||
|         <div className='doodle-modal__action-bar'> | ||||
|           <div className='doodle-toolbar'> | ||||
|             <Button text='Done' onClick={this.onDoneButton} /> | ||||
|             <Button text='Cancel' onClick={this.onCancelButton} /> | ||||
|           </div> | ||||
|           <div className='filler' /> | ||||
|           <div className='doodle-toolbar with-inputs'> | ||||
|             <div> | ||||
|               <label htmlFor='dd_smoothing'>Smoothing</label> | ||||
|               <span className='val'> | ||||
|                 <input type='checkbox' id='dd_smoothing' onChange={this.tglSmooth} checked={this.smoothing} /> | ||||
|               </span> | ||||
|             </div> | ||||
|             <div> | ||||
|               <label htmlFor='dd_adaptive'>Adaptive</label> | ||||
|               <span className='val'> | ||||
|                 <input type='checkbox' id='dd_adaptive' onChange={this.tglAdaptive} checked={this.adaptiveStroke} /> | ||||
|               </span> | ||||
|             </div> | ||||
|             <div> | ||||
|               <label htmlFor='dd_weight'>Weight</label> | ||||
|               <span className='val'> | ||||
|                 <input type='number' min={1} id='dd_weight' value={this.weight} onChange={this.setWeight} /> | ||||
|               </span> | ||||
|             </div> | ||||
|             <div> | ||||
|               <select aria-label='Canvas size' onInput={this.changeSize} defaultValue={this.size}> | ||||
|                 { Object.values(mapValues(DOODLE_SIZES, (val, k) => | ||||
|                   <option key={k} value={k}>{val[2]}</option> | ||||
|                 )) } | ||||
|               </select> | ||||
|             </div> | ||||
|           </div> | ||||
|           <div className='doodle-toolbar'> | ||||
|             <IconButton icon='pencil' title='Draw' label='Draw' onClick={this.setModeDraw} size={18} active={this.mode === 'draw'} inverted /> | ||||
|             <IconButton icon='bath' title='Fill' label='Fill' onClick={this.setModeFill} size={18} active={this.mode === 'fill'} inverted /> | ||||
|             <IconButton icon='undo' title='Undo' label='Undo' onClick={this.undo} size={18} inverted /> | ||||
|             <IconButton icon='trash' title='Clear' label='Clear' onClick={this.handleClearBtn} size={18} inverted /> | ||||
|           </div> | ||||
|           <div className='doodle-palette'> | ||||
|             { | ||||
|               palReordered.map((c, i) => | ||||
|                 c === null ? | ||||
|                   <br key={i} /> : | ||||
|                   <button | ||||
|                     key={i} | ||||
|                     style={{ backgroundColor: c[0] }} | ||||
|                     onClick={this.onPaletteClick} | ||||
|                     onContextMenu={this.onPaletteRClick} | ||||
|                     data-color={c[0]} | ||||
|                     title={c[1]} | ||||
|                     className={classNames({ | ||||
|                       'foreground': this.fg === c[0], | ||||
|                       'background': this.bg === c[0], | ||||
|                     })} | ||||
|                   /> | ||||
|               ) | ||||
|             } | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
|  | @ -8,6 +8,7 @@ import ActionsModal from './actions_modal'; | |||
| import MediaModal from './media_modal'; | ||||
| import VideoModal from './video_modal'; | ||||
| import BoostModal from './boost_modal'; | ||||
| import DoodleModal from './doodle_modal'; | ||||
| import ConfirmationModal from './confirmation_modal'; | ||||
| import FocalPointModal from './focal_point_modal'; | ||||
| import { | ||||
|  | @ -22,6 +23,7 @@ const MODAL_COMPONENTS = { | |||
|   'MEDIA': () => Promise.resolve({ default: MediaModal }), | ||||
|   'VIDEO': () => Promise.resolve({ default: VideoModal }), | ||||
|   'BOOST': () => Promise.resolve({ default: BoostModal }), | ||||
|   'DOODLE': () => Promise.resolve({ default: DoodleModal }), | ||||
|   'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }), | ||||
|   'MUTE': MuteModal, | ||||
|   'REPORT': ReportModal, | ||||
|  | @ -43,6 +45,16 @@ export default class ModalRoot extends React.PureComponent { | |||
|   getSnapshotBeforeUpdate () { | ||||
|     return { visible: !!this.props.type }; | ||||
|   } | ||||
|   state = { | ||||
|     revealed: false, | ||||
|   }; | ||||
| 
 | ||||
|   handleKeyUp = (e) => { | ||||
|     if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27) | ||||
|          && !!this.props.type && !this.props.props.noEsc) { | ||||
|       this.props.onClose(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   componentDidUpdate (prevProps, prevState, { visible }) { | ||||
|     if (visible) { | ||||
|  | @ -53,7 +65,7 @@ export default class ModalRoot extends React.PureComponent { | |||
|   } | ||||
| 
 | ||||
|   renderLoading = modalId => () => { | ||||
|     return ['MEDIA', 'VIDEO', 'BOOST', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null; | ||||
|     return ['MEDIA', 'VIDEO', 'BOOST', 'DOODLE', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null; | ||||
|   } | ||||
| 
 | ||||
|   renderError = (props) => { | ||||
|  |  | |||
|  | @ -28,6 +28,7 @@ import { | |||
|   COMPOSE_UPLOAD_CHANGE_REQUEST, | ||||
|   COMPOSE_UPLOAD_CHANGE_SUCCESS, | ||||
|   COMPOSE_UPLOAD_CHANGE_FAIL, | ||||
|   COMPOSE_DOODLE_SET, | ||||
|   COMPOSE_RESET, | ||||
| } from '../actions/compose'; | ||||
| import { TIMELINE_DELETE } from '../actions/timelines'; | ||||
|  | @ -62,6 +63,17 @@ const initialState = ImmutableMap({ | |||
|   resetFileKey: Math.floor((Math.random() * 0x10000)), | ||||
|   idempotencyKey: null, | ||||
|   tagHistory: ImmutableList(), | ||||
|   doodle: ImmutableMap({ | ||||
|     fg: 'rgb(  0,    0,    0)', | ||||
|     bg: 'rgb(255,  255,  255)', | ||||
|     swapped: false, | ||||
|     mode: 'draw', | ||||
|     size: 'normal', | ||||
|     weight: 2, | ||||
|     opacity: 1, | ||||
|     adaptiveStroke: true, | ||||
|     smoothing: false, | ||||
|   }), | ||||
| }); | ||||
| 
 | ||||
| function statusToTextMentions(state, status) { | ||||
|  | @ -330,6 +342,8 @@ export default function compose(state = initialState, action) { | |||
|         map.set('spoiler_text', ''); | ||||
|       } | ||||
|     }); | ||||
|   case COMPOSE_DOODLE_SET: | ||||
|     return state.mergeIn(['doodle'], action.options); | ||||
|   default: | ||||
|     return state; | ||||
|   } | ||||
|  |  | |||
							
								
								
									
										96
									
								
								app/javascript/styles/doodle.scss
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								app/javascript/styles/doodle.scss
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,96 @@ | |||
| $doodleBg: #d9e1e8; | ||||
| .doodle-modal { | ||||
|   @extend .boost-modal; | ||||
|   width: unset; | ||||
| } | ||||
| 
 | ||||
| .doodle-modal__container { | ||||
|   background: $doodleBg; | ||||
|   text-align: center; | ||||
|   line-height: 0; // remove weird gap under canvas | ||||
|   canvas { | ||||
|     border: 5px solid $doodleBg; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .doodle-modal__action-bar { | ||||
|   @extend .boost-modal__action-bar; | ||||
| 
 | ||||
|   .filler { | ||||
|     flex-grow: 1; | ||||
|     margin: 0; | ||||
|     padding: 0; | ||||
|   } | ||||
| 
 | ||||
|   .doodle-toolbar { | ||||
|     line-height: 1; | ||||
| 
 | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     flex-grow: 0; | ||||
|     justify-content: space-around; | ||||
| 
 | ||||
|     &.with-inputs { | ||||
|       label { | ||||
|         display: inline-block; | ||||
|         width: 70px; | ||||
|         text-align: right; | ||||
|         margin-right: 2px; | ||||
|       } | ||||
| 
 | ||||
|       input[type="number"],input[type="text"] { | ||||
|         width: 40px; | ||||
|       } | ||||
|       span.val { | ||||
|         display: inline-block; | ||||
|         text-align: left; | ||||
|         width: 50px; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   .doodle-palette { | ||||
|     padding-right: 0 !important; | ||||
|     border: 1px solid black; | ||||
|     line-height: .2rem; | ||||
|     flex-grow: 0; | ||||
|     background: white; | ||||
| 
 | ||||
|     button { | ||||
|       appearance: none; | ||||
|       width: 1rem; | ||||
|       height: 1rem; | ||||
|       margin: 0; padding: 0; | ||||
|       text-align: center; | ||||
|       color: black; | ||||
|       text-shadow: 0 0 1px white; | ||||
|       cursor: pointer; | ||||
|       box-shadow: inset 0 0 1px rgba(white, .5); | ||||
|       border: 1px solid black; | ||||
|       outline-offset:-1px; | ||||
| 
 | ||||
|       &.foreground { | ||||
|         outline: 1px dashed white; | ||||
|       } | ||||
| 
 | ||||
|       &.background { | ||||
|         outline: 1px dashed red; | ||||
|       } | ||||
| 
 | ||||
|       &.foreground.background { | ||||
|         outline: 1px dashed red; | ||||
|         border-color: white; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .compose-form__buttons-separator {  | ||||
|   border-left: 1px solid #c3c3c3;  | ||||
|   margin: 0 3px;  | ||||
| }  | ||||
| 
 | ||||
| .compose-form__upload-button-icon { | ||||
|   line-height: 27px; | ||||
| } | ||||
| 
 | ||||
|  | @ -383,7 +383,6 @@ | |||
|     padding: 10px; | ||||
|     cursor: pointer; | ||||
|     border-radius: 4px; | ||||
| 
 | ||||
|     &:hover, | ||||
|     &:focus, | ||||
|     &:active, | ||||
|  | @ -3522,6 +3521,78 @@ a.status-card.compact:hover { | |||
|   } | ||||
| } | ||||
| 
 | ||||
| .advanced-options-dropdown { | ||||
|   position: relative; | ||||
| } | ||||
| 
 | ||||
| .advanced-options-dropdown__dropdown { | ||||
|   display: none; | ||||
|   position: absolute; | ||||
|   left: 0; | ||||
|   top: 27px; | ||||
|   width: 210px; | ||||
|   background: $simple-background-color; | ||||
|   border-radius: 0 4px 4px; | ||||
|   z-index: 2; | ||||
|   overflow: hidden; | ||||
| } | ||||
| 
 | ||||
| .advanced-options-dropdown__option { | ||||
|   color: $ui-base-color; | ||||
|   padding: 10px; | ||||
|   cursor: pointer; | ||||
|   display: flex; | ||||
| 
 | ||||
|   &:hover, | ||||
|   &.active { | ||||
|     background: $ui-highlight-color; | ||||
|     color: $primary-text-color; | ||||
| 
 | ||||
|     .advanced-options-dropdown__option__content { | ||||
|       color: $primary-text-color; | ||||
| 
 | ||||
|       strong { | ||||
|         color: $primary-text-color; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   &.active:hover { | ||||
|     background: lighten($ui-highlight-color, 4%); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .advanced-options-dropdown__option__toggle { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   margin-right: 10px; | ||||
| } | ||||
| 
 | ||||
| .advanced-options-dropdown__option__content { | ||||
|   flex: 1 1 auto; | ||||
|   color: darken($ui-primary-color, 24%); | ||||
| 
 | ||||
|   strong { | ||||
|     font-weight: 500; | ||||
|     display: block; | ||||
|     color: $ui-base-color; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .advanced-options-dropdown.open { | ||||
|   .advanced-options-dropdown__value { | ||||
|     background: $simple-background-color; | ||||
|     border-radius: 4px 4px 0 0; | ||||
|     box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1); | ||||
|   } | ||||
| 
 | ||||
|   .advanced-options-dropdown__dropdown { | ||||
|     display: block; | ||||
|     box-shadow: 2px 4px 6px rgba($base-shadow-color, 0.1); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .search { | ||||
|   position: relative; | ||||
| } | ||||
|  | @ -5433,3 +5504,4 @@ noscript { | |||
|     } | ||||
|   } | ||||
| } | ||||
| @import 'doodle'; | ||||
|  |  | |||
|  | @ -40,6 +40,7 @@ | |||
|     "@babel/runtime": "^7.2.0", | ||||
|     "@gfx/zopfli": "^1.0.10", | ||||
|     "array-includes": "^3.0.3", | ||||
|     "atrament": "^0.2.3", | ||||
|     "autoprefixer": "^9.4.3", | ||||
|     "axios": "^0.18.0", | ||||
|     "babel-core": "^7.0.0-bridge.0", | ||||
|  |  | |||
|  | @ -1272,6 +1272,10 @@ atob@^2.1.1: | |||
|   resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" | ||||
|   integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== | ||||
| 
 | ||||
| atrament@^0.2.3: | ||||
|   version "0.2.3" | ||||
|   resolved "https://registry.yarnpkg.com/atrament/-/atrament-0.2.3.tgz#6ccbc0daa6d3f25e5aeaeb31befeb78e86980348" | ||||
| 
 | ||||
| autoprefixer@^9.4.3: | ||||
|   version "9.4.3" | ||||
|   resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-9.4.3.tgz#c97384a8fd80477b78049163a91bbc725d9c41d9" | ||||
|  |  | |||
		Loading…
	
	Add table
		
		Reference in a new issue