diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index a4352faab..7dcc2d8e2 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -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) {
diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js
index b96e48fd0..94e0dd86a 100644
--- a/app/javascript/mastodon/components/icon_button.js
+++ b/app/javascript/mastodon/components/icon_button.js
@@ -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}
>
-
+
+ {this.props.label}
)}
diff --git a/app/javascript/mastodon/features/compose/components/attach_options.js b/app/javascript/mastodon/features/compose/components/attach_options.js
new file mode 100644
index 000000000..1a26087e7
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/attach_options.js
@@ -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 (
+
diff --git a/app/javascript/mastodon/features/ui/components/doodle_modal.js b/app/javascript/mastodon/features/ui/components/doodle_modal.js
new file mode 100644
index 000000000..4efc9d2e6
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/doodle_modal.js
@@ -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
+}
+
+// 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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ palReordered.map((c, i) =>
+ c === null ?
+
:
+
+ )
+ }
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js
index cc2ab6c8c..493dc92e7 100644
--- a/app/javascript/mastodon/features/ui/components/modal_root.js
+++ b/app/javascript/mastodon/features/ui/components/modal_root.js
@@ -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 ?
: null;
+ return ['MEDIA', 'VIDEO', 'BOOST', 'DOODLE', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ?
: null;
}
renderError = (props) => {
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index 1622871b8..2c39ecf82 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -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;
}
diff --git a/app/javascript/styles/doodle.scss b/app/javascript/styles/doodle.scss
new file mode 100644
index 000000000..8531cc85c
--- /dev/null
+++ b/app/javascript/styles/doodle.scss
@@ -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;
+}
+
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 10e094648..bf002bfe2 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -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';
diff --git a/package.json b/package.json
index 5fa6fa8b7..2d7d6dc5a 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/yarn.lock b/yarn.lock
index 9ff12a712..052d4fc43 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"