[New Feature] Comment Emotes (#125)
* Refactor form-field * Create new Emote Menu * Add Emotes * Add Emote Selector and Emote Comment creation ability * Fix and Split CSS
This commit is contained in:
parent
762bddb158
commit
c24153c6ca
15 changed files with 632 additions and 319 deletions
68
ui/component/commentCreate/emote-selector.jsx
Normal file
68
ui/component/commentCreate/emote-selector.jsx
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
// @flow
|
||||||
|
import 'scss/component/_emote-selector.scss';
|
||||||
|
import { EMOTES_24px as EMOTES } from 'constants/emotes';
|
||||||
|
import * as ICONS from 'constants/icons';
|
||||||
|
import Button from 'component/button';
|
||||||
|
import EMOJIS from 'emoji-dictionary';
|
||||||
|
import OptimizedImage from 'component/optimizedImage';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const OLD_QUICK_EMOJIS = [
|
||||||
|
EMOJIS.getUnicode('rocket'),
|
||||||
|
EMOJIS.getUnicode('jeans'),
|
||||||
|
EMOJIS.getUnicode('fire'),
|
||||||
|
EMOJIS.getUnicode('heart'),
|
||||||
|
EMOJIS.getUnicode('open_mouth'),
|
||||||
|
];
|
||||||
|
|
||||||
|
type Props = { commentValue: string, setCommentValue: (string) => void, closeSelector: () => void };
|
||||||
|
|
||||||
|
export default function EmoteSelector(props: Props) {
|
||||||
|
const { commentValue, setCommentValue, closeSelector } = props;
|
||||||
|
|
||||||
|
function addEmoteToComment(emote: string) {
|
||||||
|
setCommentValue(
|
||||||
|
commentValue + (commentValue && commentValue.charAt(commentValue.length - 1) !== ' ' ? ` ${emote} ` : `${emote} `)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="emoteSelector">
|
||||||
|
<Button button="close" icon={ICONS.REMOVE} onClick={closeSelector} />
|
||||||
|
|
||||||
|
<div className="emoteSelector__list">
|
||||||
|
<div className="emoteSelector__listRow">
|
||||||
|
<div className="emoteSelector__listRowTitle">{__('Global Emotes')}</div>
|
||||||
|
|
||||||
|
<div className="emoteSelector__listRowItems">
|
||||||
|
{OLD_QUICK_EMOJIS.map((emoji) => (
|
||||||
|
<Button
|
||||||
|
key={emoji}
|
||||||
|
label={emoji}
|
||||||
|
title={`:${EMOJIS.getName(emoji)}:`}
|
||||||
|
button="alt"
|
||||||
|
className="button--file-action"
|
||||||
|
onClick={() => addEmoteToComment(emoji)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{EMOTES.map((emote) => {
|
||||||
|
const emoteName = emote.name.toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={String(emote)}
|
||||||
|
title={emoteName}
|
||||||
|
button="alt"
|
||||||
|
className="button--file-action"
|
||||||
|
onClick={() => addEmoteToComment(emoteName)}
|
||||||
|
>
|
||||||
|
<OptimizedImage src={emote.url} waitLoad />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
// @flow
|
// @flow
|
||||||
|
import 'scss/component/_comment-create.scss';
|
||||||
import { FF_MAX_CHARS_IN_COMMENT, FF_MAX_CHARS_IN_LIVESTREAM_COMMENT } from 'constants/form-field';
|
import { FF_MAX_CHARS_IN_COMMENT, FF_MAX_CHARS_IN_LIVESTREAM_COMMENT } from 'constants/form-field';
|
||||||
import { FormField, Form } from 'component/common/form';
|
import { FormField, Form } from 'component/common/form';
|
||||||
import { getChannelIdFromClaim } from 'util/claim';
|
import { getChannelIdFromClaim } from 'util/claim';
|
||||||
|
@ -13,6 +14,7 @@ import ChannelMentionSuggestions from 'component/channelMentionSuggestions';
|
||||||
import ChannelThumbnail from 'component/channelThumbnail';
|
import ChannelThumbnail from 'component/channelThumbnail';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import CreditAmount from 'component/common/credit-amount';
|
import CreditAmount from 'component/common/credit-amount';
|
||||||
|
import EmoteSelector from './emote-selector';
|
||||||
import Empty from 'component/common/empty';
|
import Empty from 'component/common/empty';
|
||||||
import I18nMessage from 'component/i18nMessage';
|
import I18nMessage from 'component/i18nMessage';
|
||||||
import Icon from 'component/common/icon';
|
import Icon from 'component/common/icon';
|
||||||
|
@ -106,6 +108,7 @@ export function CommentCreate(props: Props) {
|
||||||
const [deletedComment, setDeletedComment] = React.useState(false);
|
const [deletedComment, setDeletedComment] = React.useState(false);
|
||||||
const [pauseQuickSend, setPauseQuickSend] = React.useState(false);
|
const [pauseQuickSend, setPauseQuickSend] = React.useState(false);
|
||||||
const [shouldDisableReviewButton, setShouldDisableReviewButton] = React.useState();
|
const [shouldDisableReviewButton, setShouldDisableReviewButton] = React.useState();
|
||||||
|
const [showEmotes, setShowEmotes] = React.useState(false);
|
||||||
|
|
||||||
const selectedMentionIndex =
|
const selectedMentionIndex =
|
||||||
commentValue.indexOf('@', selectionIndex) === selectionIndex
|
commentValue.indexOf('@', selectionIndex) === selectionIndex
|
||||||
|
@ -205,12 +208,6 @@ export function CommentCreate(props: Props) {
|
||||||
window.removeEventListener('keydown', altEnterListener);
|
window.removeEventListener('keydown', altEnterListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit() {
|
|
||||||
if (activeChannelClaim && commentValue.length) {
|
|
||||||
handleCreateComment();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSupportComment() {
|
function handleSupportComment() {
|
||||||
if (!activeChannelClaim) {
|
if (!activeChannelClaim) {
|
||||||
return;
|
return;
|
||||||
|
@ -368,6 +365,7 @@ export function CommentCreate(props: Props) {
|
||||||
* @param {string} [environment] Optional environment for Stripe (test|live)
|
* @param {string} [environment] Optional environment for Stripe (test|live)
|
||||||
*/
|
*/
|
||||||
function handleCreateComment(txid, payment_intent_id, environment) {
|
function handleCreateComment(txid, payment_intent_id, environment) {
|
||||||
|
setShowEmotes(false);
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
createComment(commentValue, claimId, parentId, txid, payment_intent_id, environment)
|
createComment(commentValue, claimId, parentId, txid, payment_intent_id, environment)
|
||||||
|
@ -509,13 +507,20 @@ export function CommentCreate(props: Props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
onSubmit={handleSubmit}
|
|
||||||
className={classnames('comment__create', {
|
className={classnames('comment__create', {
|
||||||
'comment__create--reply': isReply,
|
'comment__create--reply': isReply,
|
||||||
'comment__create--nested-reply': isNested,
|
'comment__create--nested-reply': isNested,
|
||||||
'comment__create--bottom': bottom,
|
'comment__create--bottom': bottom,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
{showEmotes && (
|
||||||
|
<EmoteSelector
|
||||||
|
commentValue={commentValue}
|
||||||
|
setCommentValue={setCommentValue}
|
||||||
|
closeSelector={() => setShowEmotes(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{!advancedEditor && (
|
{!advancedEditor && (
|
||||||
<ChannelMentionSuggestions
|
<ChannelMentionSuggestions
|
||||||
uri={uri}
|
uri={uri}
|
||||||
|
@ -544,6 +549,7 @@ export function CommentCreate(props: Props) {
|
||||||
!SIMPLE_SITE && (isReply ? undefined : advancedEditor ? __('Simple Editor') : __('Advanced Editor'))
|
!SIMPLE_SITE && (isReply ? undefined : advancedEditor ? __('Simple Editor') : __('Advanced Editor'))
|
||||||
}
|
}
|
||||||
quickActionHandler={() => !SIMPLE_SITE && setAdvancedEditor(!advancedEditor)}
|
quickActionHandler={() => !SIMPLE_SITE && setAdvancedEditor(!advancedEditor)}
|
||||||
|
openEmoteMenu={() => setShowEmotes(!showEmotes)}
|
||||||
onFocus={onTextareaFocus}
|
onFocus={onTextareaFocus}
|
||||||
onBlur={onTextareaBlur}
|
onBlur={onTextareaBlur}
|
||||||
placeholder={__('Say something about this...')}
|
placeholder={__('Say something about this...')}
|
||||||
|
@ -601,6 +607,7 @@ export function CommentCreate(props: Props) {
|
||||||
: __('Comment --[button to submit something]--')
|
: __('Comment --[button to submit something]--')
|
||||||
}
|
}
|
||||||
requiresAuth={IS_WEB}
|
requiresAuth={IS_WEB}
|
||||||
|
onClick={() => activeChannelClaim && commentValue.length && handleCreateComment()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!supportDisabled && !claimIsMine && (
|
{!supportDisabled && !claimIsMine && (
|
||||||
|
|
|
@ -1,33 +1,23 @@
|
||||||
// @flow
|
// @flow
|
||||||
import type { ElementRef, Node } from 'react';
|
import 'easymde/dist/easymde.min.css';
|
||||||
|
import { FF_MAX_CHARS_DEFAULT } from 'constants/form-field';
|
||||||
|
import { openEditorMenu, stopContextMenu } from 'util/context-menu';
|
||||||
|
import * as ICONS from 'constants/icons';
|
||||||
|
import Button from 'component/button';
|
||||||
|
import MarkdownPreview from 'component/common/markdown-preview';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOMServer from 'react-dom/server';
|
import ReactDOMServer from 'react-dom/server';
|
||||||
import SimpleMDE from 'react-simplemde-editor';
|
import SimpleMDE from 'react-simplemde-editor';
|
||||||
import MarkdownPreview from 'component/common/markdown-preview';
|
import type { ElementRef, Node } from 'react';
|
||||||
import { openEditorMenu, stopContextMenu } from 'util/context-menu';
|
|
||||||
import { FF_MAX_CHARS_DEFAULT } from 'constants/form-field';
|
|
||||||
import 'easymde/dist/easymde.min.css';
|
|
||||||
import Button from 'component/button';
|
|
||||||
import emoji from 'emoji-dictionary';
|
|
||||||
|
|
||||||
const QUICK_EMOJIS = [
|
|
||||||
emoji.getUnicode('rocket'),
|
|
||||||
emoji.getUnicode('jeans'),
|
|
||||||
emoji.getUnicode('fire'),
|
|
||||||
emoji.getUnicode('heart'),
|
|
||||||
emoji.getUnicode('open_mouth'),
|
|
||||||
];
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
name: string,
|
name: string,
|
||||||
label?: string | Node,
|
label?: string | Node,
|
||||||
render?: () => React$Node,
|
|
||||||
prefix?: string,
|
prefix?: string,
|
||||||
postfix?: string,
|
postfix?: string,
|
||||||
error?: string | boolean,
|
error?: string | boolean,
|
||||||
helper?: string | React$Node,
|
helper?: string | React$Node,
|
||||||
type?: string,
|
type?: string,
|
||||||
onChange?: (any) => any,
|
|
||||||
defaultValue?: string | number,
|
defaultValue?: string | number,
|
||||||
placeholder?: string | number,
|
placeholder?: string | number,
|
||||||
children?: React$Node,
|
children?: React$Node,
|
||||||
|
@ -43,18 +33,17 @@ type Props = {
|
||||||
min?: number,
|
min?: number,
|
||||||
max?: number,
|
max?: number,
|
||||||
quickActionLabel?: string,
|
quickActionLabel?: string,
|
||||||
quickActionHandler?: (any) => any,
|
|
||||||
disabled?: boolean,
|
disabled?: boolean,
|
||||||
onChange: (any) => void,
|
|
||||||
value?: string | number,
|
value?: string | number,
|
||||||
noEmojis?: boolean,
|
noEmojis?: boolean,
|
||||||
|
render?: () => React$Node,
|
||||||
|
onChange?: (any) => any,
|
||||||
|
quickActionHandler?: (any) => any,
|
||||||
|
openEmoteMenu?: () => void,
|
||||||
};
|
};
|
||||||
|
|
||||||
export class FormField extends React.PureComponent<Props> {
|
export class FormField extends React.PureComponent<Props> {
|
||||||
static defaultProps = {
|
static defaultProps = { labelOnLeft: false, blockWrap: true };
|
||||||
labelOnLeft: false,
|
|
||||||
blockWrap: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
input: { current: ElementRef<any> };
|
input: { current: ElementRef<any> };
|
||||||
|
|
||||||
|
@ -67,14 +56,11 @@ export class FormField extends React.PureComponent<Props> {
|
||||||
const { autoFocus } = this.props;
|
const { autoFocus } = this.props;
|
||||||
const input = this.input.current;
|
const input = this.input.current;
|
||||||
|
|
||||||
if (input && autoFocus) {
|
if (input && autoFocus) input.focus();
|
||||||
input.focus();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
render,
|
|
||||||
label,
|
label,
|
||||||
prefix,
|
prefix,
|
||||||
postfix,
|
postfix,
|
||||||
|
@ -92,11 +78,24 @@ export class FormField extends React.PureComponent<Props> {
|
||||||
charCount,
|
charCount,
|
||||||
textAreaMaxLength,
|
textAreaMaxLength,
|
||||||
quickActionLabel,
|
quickActionLabel,
|
||||||
quickActionHandler,
|
|
||||||
noEmojis,
|
noEmojis,
|
||||||
|
render,
|
||||||
|
quickActionHandler,
|
||||||
|
openEmoteMenu,
|
||||||
...inputProps
|
...inputProps
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const errorMessage = typeof error === 'object' ? error.message : error;
|
const errorMessage = typeof error === 'object' ? error.message : error;
|
||||||
|
|
||||||
|
// Ideally, the character count should (and can) be appended to the
|
||||||
|
// SimpleMDE's "options::status" bar. However, I couldn't figure out how
|
||||||
|
// to pass the current value to it's callback, nor query the current
|
||||||
|
// text length from the callback. So, we'll use our own widget.
|
||||||
|
const hasCharCount = charCount !== undefined && charCount >= 0;
|
||||||
|
const countInfo = hasCharCount && textAreaMaxLength !== undefined && (
|
||||||
|
<span className="comment__char-count-mde">{`${charCount || '0'}/${textAreaMaxLength}`}</span>
|
||||||
|
);
|
||||||
|
|
||||||
const Wrapper = blockWrap
|
const Wrapper = blockWrap
|
||||||
? ({ children: innerChildren }) => <fieldset-section class="radio">{innerChildren}</fieldset-section>
|
? ({ children: innerChildren }) => <fieldset-section class="radio">{innerChildren}</fieldset-section>
|
||||||
: ({ children: innerChildren }) => <span className="radio">{innerChildren}</span>;
|
: ({ children: innerChildren }) => <span className="radio">{innerChildren}</span>;
|
||||||
|
@ -108,207 +107,164 @@ export class FormField extends React.PureComponent<Props> {
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
let input;
|
const inputSimple = (type: string) => (
|
||||||
if (type) {
|
<>
|
||||||
if (type === 'radio') {
|
<input id={name} type={type} {...inputProps} />
|
||||||
input = (
|
<label htmlFor={name}>{label}</label>
|
||||||
<Wrapper>
|
</>
|
||||||
<input id={name} type="radio" {...inputProps} />
|
);
|
||||||
<label htmlFor={name}>{label}</label>
|
|
||||||
</Wrapper>
|
|
||||||
);
|
|
||||||
} else if (type === 'checkbox') {
|
|
||||||
input = (
|
|
||||||
<div className="checkbox">
|
|
||||||
<input id={name} type="checkbox" {...inputProps} />
|
|
||||||
<label htmlFor={name}>{label}</label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (type === 'range') {
|
|
||||||
input = (
|
|
||||||
<div>
|
|
||||||
<input id={name} type="range" {...inputProps} />
|
|
||||||
<label htmlFor={name}>{label}</label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (type === 'select') {
|
|
||||||
input = (
|
|
||||||
<fieldset-section>
|
|
||||||
{(label || errorMessage) && (
|
|
||||||
<label htmlFor={name}>{errorMessage ? <span className="error__text">{errorMessage}</span> : label}</label>
|
|
||||||
)}
|
|
||||||
<select id={name} {...inputProps}>
|
|
||||||
{children}
|
|
||||||
</select>
|
|
||||||
</fieldset-section>
|
|
||||||
);
|
|
||||||
} else if (type === 'select-tiny') {
|
|
||||||
input = (
|
|
||||||
<fieldset-section class="select--slim">
|
|
||||||
{(label || errorMessage) && (
|
|
||||||
<label htmlFor={name}>{errorMessage ? <span className="error__text">{errorMessage}</span> : label}</label>
|
|
||||||
)}
|
|
||||||
<select id={name} {...inputProps}>
|
|
||||||
{children}
|
|
||||||
</select>
|
|
||||||
</fieldset-section>
|
|
||||||
);
|
|
||||||
} else if (type === 'markdown') {
|
|
||||||
const handleEvents = {
|
|
||||||
contextmenu: openEditorMenu,
|
|
||||||
};
|
|
||||||
|
|
||||||
const getInstance = (editor) => {
|
const inputSelect = (selectClass: string) => (
|
||||||
// SimpleMDE max char check
|
<fieldset-section class={selectClass}>
|
||||||
editor.codemirror.on('beforeChange', (instance, changes) => {
|
{(label || errorMessage) && (
|
||||||
if (textAreaMaxLength && changes.update) {
|
<label htmlFor={name}>{errorMessage ? <span className="error__text">{errorMessage}</span> : label}</label>
|
||||||
var str = changes.text.join('\n');
|
)}
|
||||||
var delta = str.length - (instance.indexFromPos(changes.to) - instance.indexFromPos(changes.from));
|
<select id={name} {...inputProps}>
|
||||||
if (delta <= 0) {
|
{children}
|
||||||
return;
|
</select>
|
||||||
}
|
</fieldset-section>
|
||||||
delta = instance.getValue().length + delta - textAreaMaxLength;
|
);
|
||||||
if (delta > 0) {
|
|
||||||
str = str.substr(0, str.length - delta);
|
|
||||||
changes.update(changes.from, changes.to, str.split('\n'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// "Create Link (Ctrl-K)": highlight URL instead of label:
|
const input = () => {
|
||||||
editor.codemirror.on('changes', (instance, changes) => {
|
switch (type) {
|
||||||
try {
|
case 'radio':
|
||||||
// Grab the last change from the buffered list. I assume the
|
return <Wrapper>{inputSimple('radio')}</Wrapper>;
|
||||||
// buffered one ('changes', instead of 'change') is more efficient,
|
case 'checkbox':
|
||||||
// and that "Create Link" will always end up last in the list.
|
return <div className="checkbox">{inputSimple('checkbox')}</div>;
|
||||||
const lastChange = changes[changes.length - 1];
|
case 'range':
|
||||||
if (lastChange.origin === '+input') {
|
return <div>{inputSimple('range')}</div>;
|
||||||
// https://github.com/Ionaru/easy-markdown-editor/blob/8fa54c496f98621d5f45f57577ce630bee8c41ee/src/js/easymde.js#L765
|
case 'select':
|
||||||
const EASYMDE_URL_PLACEHOLDER = '(https://)';
|
return inputSelect('');
|
||||||
|
case 'select-tiny':
|
||||||
|
return inputSelect('select--slim');
|
||||||
|
case 'markdown':
|
||||||
|
const handleEvents = { contextmenu: openEditorMenu };
|
||||||
|
|
||||||
// The URL placeholder is always placed last, so just look at the
|
const getInstance = (editor) => {
|
||||||
// last text in the array to also cover the multi-line case:
|
// SimpleMDE max char check
|
||||||
const urlLineText = lastChange.text[lastChange.text.length - 1];
|
editor.codemirror.on('beforeChange', (instance, changes) => {
|
||||||
|
if (textAreaMaxLength && changes.update) {
|
||||||
|
var str = changes.text.join('\n');
|
||||||
|
var delta = str.length - (instance.indexFromPos(changes.to) - instance.indexFromPos(changes.from));
|
||||||
|
|
||||||
if (urlLineText.endsWith(EASYMDE_URL_PLACEHOLDER) && urlLineText !== '[]' + EASYMDE_URL_PLACEHOLDER) {
|
if (delta <= 0) return;
|
||||||
const from = lastChange.from;
|
|
||||||
const to = lastChange.to;
|
|
||||||
const isSelectionMultiline = lastChange.text.length > 1;
|
|
||||||
const baseIndex = isSelectionMultiline ? 0 : from.ch;
|
|
||||||
|
|
||||||
// Everything works fine for the [Ctrl-K] case, but for the
|
delta = instance.getValue().length + delta - textAreaMaxLength;
|
||||||
// [Button] case, this handler happens before the original
|
if (delta > 0) {
|
||||||
// code, thus our change got wiped out.
|
str = str.substr(0, str.length - delta);
|
||||||
// Add a small delay to handle that case.
|
changes.update(changes.from, changes.to, str.split('\n'));
|
||||||
setTimeout(() => {
|
|
||||||
instance.setSelection(
|
|
||||||
{ line: to.line, ch: baseIndex + urlLineText.lastIndexOf('(') + 1 },
|
|
||||||
{ line: to.line, ch: baseIndex + urlLineText.lastIndexOf(')') }
|
|
||||||
);
|
|
||||||
}, 25);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
});
|
||||||
// Do nothing (revert to original behavior)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ideally, the character count should (and can) be appended to the
|
// "Create Link (Ctrl-K)": highlight URL instead of label:
|
||||||
// SimpleMDE's "options::status" bar. However, I couldn't figure out how
|
editor.codemirror.on('changes', (instance, changes) => {
|
||||||
// to pass the current value to it's callback, nor query the current
|
try {
|
||||||
// text length from the callback. So, we'll use our own widget.
|
// Grab the last change from the buffered list. I assume the
|
||||||
const hasCharCount = charCount !== undefined && charCount >= 0;
|
// buffered one ('changes', instead of 'change') is more efficient,
|
||||||
const countInfo = hasCharCount && textAreaMaxLength !== undefined && (
|
// and that "Create Link" will always end up last in the list.
|
||||||
<span className="comment__char-count-mde">{`${charCount || '0'}/${textAreaMaxLength}`}</span>
|
const lastChange = changes[changes.length - 1];
|
||||||
);
|
if (lastChange.origin === '+input') {
|
||||||
|
// https://github.com/Ionaru/easy-markdown-editor/blob/8fa54c496f98621d5f45f57577ce630bee8c41ee/src/js/easymde.js#L765
|
||||||
|
const EASYMDE_URL_PLACEHOLDER = '(https://)';
|
||||||
|
|
||||||
input = (
|
// The URL placeholder is always placed last, so just look at the
|
||||||
<div className="form-field--SimpleMDE" onContextMenu={stopContextMenu}>
|
// last text in the array to also cover the multi-line case:
|
||||||
|
const urlLineText = lastChange.text[lastChange.text.length - 1];
|
||||||
|
|
||||||
|
if (urlLineText.endsWith(EASYMDE_URL_PLACEHOLDER) && urlLineText !== '[]' + EASYMDE_URL_PLACEHOLDER) {
|
||||||
|
const from = lastChange.from;
|
||||||
|
const to = lastChange.to;
|
||||||
|
const isSelectionMultiline = lastChange.text.length > 1;
|
||||||
|
const baseIndex = isSelectionMultiline ? 0 : from.ch;
|
||||||
|
|
||||||
|
// Everything works fine for the [Ctrl-K] case, but for the
|
||||||
|
// [Button] case, this handler happens before the original
|
||||||
|
// code, thus our change got wiped out.
|
||||||
|
// Add a small delay to handle that case.
|
||||||
|
setTimeout(() => {
|
||||||
|
instance.setSelection(
|
||||||
|
{ line: to.line, ch: baseIndex + urlLineText.lastIndexOf('(') + 1 },
|
||||||
|
{ line: to.line, ch: baseIndex + urlLineText.lastIndexOf(')') }
|
||||||
|
);
|
||||||
|
}, 25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {} // Do nothing (revert to original behavior)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="form-field--SimpleMDE" onContextMenu={stopContextMenu}>
|
||||||
|
<fieldset-section>
|
||||||
|
<div className="form-field__two-column">
|
||||||
|
<div>
|
||||||
|
<label htmlFor={name}>{label}</label>
|
||||||
|
</div>
|
||||||
|
{quickAction}
|
||||||
|
</div>
|
||||||
|
<SimpleMDE
|
||||||
|
{...inputProps}
|
||||||
|
id={name}
|
||||||
|
type="textarea"
|
||||||
|
events={handleEvents}
|
||||||
|
getMdeInstance={getInstance}
|
||||||
|
options={{
|
||||||
|
spellChecker: true,
|
||||||
|
hideIcons: ['heading', 'image', 'fullscreen', 'side-by-side'],
|
||||||
|
previewRender(plainText) {
|
||||||
|
const preview = <MarkdownPreview content={plainText} noDataStore />;
|
||||||
|
return ReactDOMServer.renderToString(preview);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{countInfo}
|
||||||
|
</fieldset-section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'textarea':
|
||||||
|
return (
|
||||||
<fieldset-section>
|
<fieldset-section>
|
||||||
<div className="form-field__two-column">
|
{(label || quickAction) && (
|
||||||
<div>
|
<div className="form-field__two-column">
|
||||||
<label htmlFor={name}>{label}</label>
|
<label htmlFor={name}>{label}</label>
|
||||||
</div>
|
{quickAction}
|
||||||
{quickAction}
|
|
||||||
</div>
|
|
||||||
<SimpleMDE
|
|
||||||
{...inputProps}
|
|
||||||
id={name}
|
|
||||||
type="textarea"
|
|
||||||
events={handleEvents}
|
|
||||||
getMdeInstance={getInstance}
|
|
||||||
options={{
|
|
||||||
spellChecker: true,
|
|
||||||
hideIcons: ['heading', 'image', 'fullscreen', 'side-by-side'],
|
|
||||||
previewRender(plainText) {
|
|
||||||
const preview = <MarkdownPreview content={plainText} noDataStore />;
|
|
||||||
return ReactDOMServer.renderToString(preview);
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{countInfo}
|
|
||||||
</fieldset-section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (type === 'textarea') {
|
|
||||||
const hasCharCount = charCount !== undefined && charCount >= 0;
|
|
||||||
const countInfo = hasCharCount && textAreaMaxLength !== undefined && (
|
|
||||||
<span className="comment__char-count">{`${charCount || '0'}/${textAreaMaxLength}`}</span>
|
|
||||||
);
|
|
||||||
input = (
|
|
||||||
<fieldset-section>
|
|
||||||
{(label || quickAction) && (
|
|
||||||
<div className="form-field__two-column">
|
|
||||||
<div>
|
|
||||||
<label htmlFor={name}>{label}</label>
|
|
||||||
</div>
|
|
||||||
{quickAction}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<textarea
|
|
||||||
type={type}
|
|
||||||
id={name}
|
|
||||||
maxLength={textAreaMaxLength || FF_MAX_CHARS_DEFAULT}
|
|
||||||
ref={this.input}
|
|
||||||
{...inputProps}
|
|
||||||
/>
|
|
||||||
<div className="form-field__textarea-info">
|
|
||||||
{!noEmojis && (
|
|
||||||
<div className="form-field__quick-emojis">
|
|
||||||
{QUICK_EMOJIS.map((emoji) => (
|
|
||||||
<Button
|
|
||||||
key={emoji}
|
|
||||||
disabled={inputProps.disabled}
|
|
||||||
type="button"
|
|
||||||
className="button--emoji"
|
|
||||||
label={emoji}
|
|
||||||
onClick={() => {
|
|
||||||
inputProps.onChange({
|
|
||||||
target: { value: inputProps.value ? `${inputProps.value} ${emoji}` : emoji },
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{countInfo}
|
<textarea
|
||||||
</div>
|
type={type}
|
||||||
</fieldset-section>
|
id={name}
|
||||||
);
|
maxLength={textAreaMaxLength || FF_MAX_CHARS_DEFAULT}
|
||||||
} else {
|
ref={this.input}
|
||||||
const inputElement = <input type={type} id={name} {...inputProps} ref={this.input} />;
|
{...inputProps}
|
||||||
const inner = inputButton ? (
|
/>
|
||||||
<input-submit>
|
<div className="form-field__textarea-info">
|
||||||
{inputElement}
|
{!noEmojis && (
|
||||||
{inputButton}
|
<Button
|
||||||
</input-submit>
|
type="alt"
|
||||||
) : (
|
className="button--file-action"
|
||||||
inputElement
|
title="Emotes"
|
||||||
);
|
onClick={openEmoteMenu}
|
||||||
|
icon={ICONS.EMOJI}
|
||||||
|
iconSize={20}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{countInfo}
|
||||||
|
</div>
|
||||||
|
</fieldset-section>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
const inputElement = <input type={type} id={name} {...inputProps} ref={this.input} />;
|
||||||
|
const inner = inputButton ? (
|
||||||
|
<input-submit>
|
||||||
|
{inputElement}
|
||||||
|
{inputButton}
|
||||||
|
</input-submit>
|
||||||
|
) : (
|
||||||
|
inputElement
|
||||||
|
);
|
||||||
|
|
||||||
input = (
|
return (
|
||||||
<React.Fragment>
|
|
||||||
<fieldset-section>
|
<fieldset-section>
|
||||||
{(label || errorMessage) && (
|
{(label || errorMessage) && (
|
||||||
<label htmlFor={name}>
|
<label htmlFor={name}>
|
||||||
|
@ -318,17 +274,15 @@ export class FormField extends React.PureComponent<Props> {
|
||||||
{prefix && <label htmlFor={name}>{prefix}</label>}
|
{prefix && <label htmlFor={name}>{prefix}</label>}
|
||||||
{inner}
|
{inner}
|
||||||
</fieldset-section>
|
</fieldset-section>
|
||||||
</React.Fragment>
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<>
|
||||||
{input}
|
{type && input()}
|
||||||
|
|
||||||
{helper && <div className="form-field__help">{helper}</div>}
|
{helper && <div className="form-field__help">{helper}</div>}
|
||||||
</React.Fragment>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2526,4 +2526,12 @@ export const icons = {
|
||||||
<line x1="19" y1="20.5" x2="20" y2="20.5" />
|
<line x1="19" y1="20.5" x2="20" y2="20.5" />
|
||||||
</g>
|
</g>
|
||||||
),
|
),
|
||||||
|
[ICONS.EMOJI]: buildIcon(
|
||||||
|
<g>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
|
||||||
|
<line x1="9" y1="9" x2="9.01" y2="9" />
|
||||||
|
<line x1="15" y1="9" x2="15.01" y2="9" />
|
||||||
|
</g>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
|
@ -12,10 +12,14 @@ import MarkdownLink from 'component/markdownLink';
|
||||||
import defaultSchema from 'hast-util-sanitize/lib/github.json';
|
import defaultSchema from 'hast-util-sanitize/lib/github.json';
|
||||||
import { formattedLinks, inlineLinks } from 'util/remark-lbry';
|
import { formattedLinks, inlineLinks } from 'util/remark-lbry';
|
||||||
import { formattedTimestamp, inlineTimestamp } from 'util/remark-timestamp';
|
import { formattedTimestamp, inlineTimestamp } from 'util/remark-timestamp';
|
||||||
|
import { formattedEmote, inlineEmote } from 'util/remark-emote';
|
||||||
import ZoomableImage from 'component/zoomableImage';
|
import ZoomableImage from 'component/zoomableImage';
|
||||||
import { CHANNEL_STAKED_LEVEL_VIDEO_COMMENTS, SIMPLE_SITE } from 'config';
|
import { CHANNEL_STAKED_LEVEL_VIDEO_COMMENTS, SIMPLE_SITE } from 'config';
|
||||||
import Button from 'component/button';
|
import Button from 'component/button';
|
||||||
import * as ICONS from 'constants/icons';
|
import * as ICONS from 'constants/icons';
|
||||||
|
import OptimizedImage from 'component/optimizedImage';
|
||||||
|
|
||||||
|
const RE_EMOTE = /:\+1:|:-1:|:[\w-]+:/;
|
||||||
|
|
||||||
type SimpleTextProps = {
|
type SimpleTextProps = {
|
||||||
children?: React.Node,
|
children?: React.Node,
|
||||||
|
@ -94,10 +98,15 @@ const SimpleLink = (props: SimpleLinkProps) => {
|
||||||
|
|
||||||
const SimpleImageLink = (props: ImageLinkProps) => {
|
const SimpleImageLink = (props: ImageLinkProps) => {
|
||||||
const { src, title, alt, helpText } = props;
|
const { src, title, alt, helpText } = props;
|
||||||
|
|
||||||
if (!src) {
|
if (!src) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (title && RE_EMOTE.test(title) && src.includes('static.odycdn.com/emoticons')) {
|
||||||
|
return <OptimizedImage title={title} src={src} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
button="link"
|
button="link"
|
||||||
|
@ -248,6 +257,8 @@ const MarkdownPreview = (props: MarkdownProps) => {
|
||||||
.use(disableTimestamps || isMarkdownPost ? null : inlineTimestamp)
|
.use(disableTimestamps || isMarkdownPost ? null : inlineTimestamp)
|
||||||
.use(disableTimestamps || isMarkdownPost ? null : formattedTimestamp)
|
.use(disableTimestamps || isMarkdownPost ? null : formattedTimestamp)
|
||||||
// Emojis
|
// Emojis
|
||||||
|
.use(inlineEmote)
|
||||||
|
.use(formattedEmote)
|
||||||
.use(remarkEmoji)
|
.use(remarkEmoji)
|
||||||
// Render new lines without needing spaces.
|
// Render new lines without needing spaces.
|
||||||
.use(remarkBreaks)
|
.use(remarkBreaks)
|
||||||
|
|
|
@ -10,10 +10,11 @@ function scaleToDevicePixelRatio(value: number, window: any) {
|
||||||
type Props = {
|
type Props = {
|
||||||
src: string,
|
src: string,
|
||||||
objectFit?: string,
|
objectFit?: string,
|
||||||
|
waitLoad?: boolean,
|
||||||
};
|
};
|
||||||
|
|
||||||
function OptimizedImage(props: Props) {
|
function OptimizedImage(props: Props) {
|
||||||
const { objectFit, src, ...imgProps } = props;
|
const { objectFit, src, waitLoad, ...imgProps } = props;
|
||||||
const [optimizedSrc, setOptimizedSrc] = React.useState('');
|
const [optimizedSrc, setOptimizedSrc] = React.useState('');
|
||||||
const ref = React.useRef<any>();
|
const ref = React.useRef<any>();
|
||||||
|
|
||||||
|
@ -101,8 +102,12 @@ function OptimizedImage(props: Props) {
|
||||||
<img
|
<img
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...imgProps}
|
{...imgProps}
|
||||||
|
style={{ visibility: waitLoad ? 'hidden' : 'visible' }}
|
||||||
src={optimizedSrc}
|
src={optimizedSrc}
|
||||||
onLoad={() => adjustOptimizationIfNeeded(ref.current, objectFit, src)}
|
onLoad={() => {
|
||||||
|
if (waitLoad) ref.current.style.visibility = 'visible';
|
||||||
|
adjustOptimizationIfNeeded(ref.current, objectFit, src);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
96
ui/constants/emotes.js
Normal file
96
ui/constants/emotes.js
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
// @flow
|
||||||
|
|
||||||
|
const buildCDNUrl = (path: string) => `https://static.odycdn.com/emoticons/${path}`;
|
||||||
|
|
||||||
|
const buildEmote = (name: string, path: string) => ({
|
||||||
|
name: __(`:${name}:`),
|
||||||
|
url: buildCDNUrl(path),
|
||||||
|
});
|
||||||
|
|
||||||
|
const getEmotes = (px: string, multiplier: string) => [
|
||||||
|
buildEmote('ALIEN', `${px}/Alien${multiplier}.png`),
|
||||||
|
buildEmote('ANGRY_2', `${px}/angry%202${multiplier}.png`),
|
||||||
|
buildEmote('ANGRY_3', `${px}/angry%203${multiplier}.png`),
|
||||||
|
buildEmote('ANGRY_4', `${px}/angry%204${multiplier}.png`),
|
||||||
|
buildEmote('ANGRY_1', `${px}/angry${multiplier}.png`),
|
||||||
|
buildEmote('BLIND', `${px}/blind${multiplier}.png`),
|
||||||
|
buildEmote('BLOCK', `${px}/block${multiplier}.png`),
|
||||||
|
buildEmote('BOMB', `${px}/bomb${multiplier}.png`),
|
||||||
|
buildEmote('BRAIN_CHIP', `${px}/Brain%20chip${multiplier}.png`),
|
||||||
|
buildEmote('CONFIRM', `${px}/CONFIRM${multiplier}.png`),
|
||||||
|
buildEmote('CONFUSED_1', `${px}/confused-1${multiplier}.png`),
|
||||||
|
buildEmote('CONFUSED_2', `${px}/confused${multiplier}.png`),
|
||||||
|
buildEmote('COOKING_SOMETHING_NICE', `${px}/cooking%20something%20nice${multiplier}.png`),
|
||||||
|
buildEmote('CRY_2', `${px}/cry%202${multiplier}.png`),
|
||||||
|
buildEmote('CRY_3', `${px}/cry%203${multiplier}.png`),
|
||||||
|
buildEmote('CRY_4', `${px}/cry%204${multiplier}.png`),
|
||||||
|
buildEmote('CRY_5', `${px}/cry%205${multiplier}.png`),
|
||||||
|
buildEmote('CRY_1', `${px}/cry${multiplier}.png`),
|
||||||
|
buildEmote('SPACE_DOGE', `${px}/doge${multiplier}.png`),
|
||||||
|
buildEmote('DONUT', `${px}/donut${multiplier}.png`),
|
||||||
|
buildEmote('EGGPLANT_WITH_CONDOM', `${px}/eggplant%20with%20condom${multiplier}.png`),
|
||||||
|
buildEmote('EGGPLANT', `${px}/eggplant${multiplier}.png`),
|
||||||
|
buildEmote('FIRE_UP', `${px}/fire%20up${multiplier}.png`),
|
||||||
|
buildEmote('FLAT_EARTH', `${px}/Flat%20earth${multiplier}.png`),
|
||||||
|
buildEmote('FLYING_SAUCER', `${px}/Flying%20saucer${multiplier}.png`),
|
||||||
|
buildEmote('HEART_CHOPPER', `${px}/heart%20chopper${multiplier}.png`),
|
||||||
|
buildEmote('HYPER_TROLL', `${px}/HyperTroll${multiplier}.png`),
|
||||||
|
buildEmote('ICE_CREAM', `${px}/ice%20cream${multiplier}.png`),
|
||||||
|
buildEmote('IDK', `${px}/IDK${multiplier}.png`),
|
||||||
|
buildEmote('ILLUMINATI_1', `${px}/Illuminati-1${multiplier}.png`),
|
||||||
|
buildEmote('ILLUMINATI_2', `${px}/Illuminati${multiplier}.png`),
|
||||||
|
buildEmote('KISS_2', `${px}/kiss%202${multiplier}.png`),
|
||||||
|
buildEmote('KISS_1', `${px}/kiss${multiplier}.png`),
|
||||||
|
buildEmote('LASER_GUN', `${px}/laser%20gun${multiplier}.png`),
|
||||||
|
buildEmote('LAUGHING_2', `${px}/Laughing 2${multiplier}.png`),
|
||||||
|
buildEmote('LAUGHING_1', `${px}/Laughing${multiplier}.png`),
|
||||||
|
buildEmote('LOLLIPOP', `${px}/Lollipop${multiplier}.png`),
|
||||||
|
buildEmote('LOVE_2', `${px}/Love%202${multiplier}.png`),
|
||||||
|
buildEmote('LOVE_1', `${px}/Love${multiplier}.png`),
|
||||||
|
buildEmote('MONSTER', `${px}/Monster${multiplier}.png`),
|
||||||
|
buildEmote('MUSHROOM', `${px}/mushroom${multiplier}.png`),
|
||||||
|
buildEmote('NAIL_IT', `${px}/Nail%20It${multiplier}.png`),
|
||||||
|
buildEmote('NO', `${px}/NO${multiplier}.png`),
|
||||||
|
buildEmote('OUCH', `${px}/ouch${multiplier}.png`),
|
||||||
|
buildEmote('PREACE', `${px}/peace${multiplier}.png`),
|
||||||
|
buildEmote('PIZZA', `${px}/pizza${multiplier}.png`),
|
||||||
|
buildEmote('RABBIT_HOLE', `${px}/rabbit%20hole${multiplier}.png`),
|
||||||
|
buildEmote('RAINBOW_PUKE_1', `${px}/rainbow%20puke-1${multiplier}.png`),
|
||||||
|
buildEmote('RAINBOW_PUKE_2', `${px}/rainbow%20puke${multiplier}.png`),
|
||||||
|
buildEmote('SPACE_RESITAS', `${px}/resitas${multiplier}.png`),
|
||||||
|
buildEmote('ROCK', `${px}/ROCK${multiplier}.png`),
|
||||||
|
buildEmote('SAD', `${px}/sad${multiplier}.png`),
|
||||||
|
buildEmote('SALTY', `${px}/salty${multiplier}.png`),
|
||||||
|
buildEmote('SCARY', `${px}/scary${multiplier}.png`),
|
||||||
|
buildEmote('SLEEP', `${px}/Sleep${multiplier}.png`),
|
||||||
|
buildEmote('SLIME_DOWN', `${px}/slime%20down${multiplier}.png`),
|
||||||
|
buildEmote('SMELLY_SOCKS', `${px}/smelly%20socks${multiplier}.png`),
|
||||||
|
buildEmote('SMILE_2', `${px}/smile%202${multiplier}.png`),
|
||||||
|
buildEmote('SMILE_1', `${px}/smile${multiplier}.png`),
|
||||||
|
buildEmote('SPACE_CHAD', `${px}/space%20chad${multiplier}.png`),
|
||||||
|
buildEmote('SPACE_JULIAN', `${px}/Space%20Julian${multiplier}.png`),
|
||||||
|
buildEmote('SPACE_TOM', `${px}/space%20Tom${multiplier}.png`),
|
||||||
|
buildEmote('SPACE_WOJAK_1', `${px}/space%20wojak-1${multiplier}.png`),
|
||||||
|
buildEmote('ANGRY_3', `${px}/space%20wojak${multiplier}.png`),
|
||||||
|
buildEmote('SPOCK', `${px}/SPOCK${multiplier}.png`),
|
||||||
|
buildEmote('STAR', `${px}/Star${multiplier}.png`),
|
||||||
|
buildEmote('SUNNY_DAY', `${px}/sunny%20day${multiplier}.png`),
|
||||||
|
buildEmote('SUPRISED', `${px}/surprised${multiplier}.png`),
|
||||||
|
buildEmote('SWEET', `${px}/sweet${multiplier}.png`),
|
||||||
|
buildEmote('THINKING_1', `${px}/thinking-1${multiplier}.png`),
|
||||||
|
buildEmote('THINKING_2', `${px}/thinking${multiplier}.png`),
|
||||||
|
buildEmote('THUMB_DOWN', `${px}/thumb%20down${multiplier}.png`),
|
||||||
|
buildEmote('THUMB_UP_1', `${px}/thumb%20up-1${multiplier}.png`),
|
||||||
|
buildEmote('THUMB_UP_2', `${px}/thumb%20up${multiplier}.png`),
|
||||||
|
buildEmote('TINFOIL_HAT', `${px}/tin%20hat${multiplier}.png`),
|
||||||
|
buildEmote('TROLL_KING', `${px}/Troll%20king${multiplier}.png`),
|
||||||
|
buildEmote('UFO', `${px}/ufo${multiplier}.png`),
|
||||||
|
buildEmote('WAITING', `${px}/waiting${multiplier}.png`),
|
||||||
|
buildEmote('WHAT', `${px}/what_${multiplier}.png`),
|
||||||
|
buildEmote('WOODOO_DOLL', `${px}/woodo%20doll${multiplier}.png`),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const EMOTES_24px = getEmotes('24%20px', '');
|
||||||
|
export const EMOTES_36px = getEmotes('36px', '@1.5x');
|
||||||
|
export const EMOTES_48px = getEmotes('48%20px', '@2x');
|
||||||
|
export const EMOTES_72px = getEmotes('72%20px', '@3x');
|
|
@ -179,3 +179,4 @@ export const LIFE = 'Life';
|
||||||
export const ARTISTS = 'Artists';
|
export const ARTISTS = 'Artists';
|
||||||
export const MYSTERIES = 'Mysteries';
|
export const MYSTERIES = 'Mysteries';
|
||||||
export const TECHNOLOGY = 'Technology';
|
export const TECHNOLOGY = 'Technology';
|
||||||
|
export const EMOJI = 'Emoji';
|
||||||
|
|
|
@ -176,12 +176,12 @@
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vjs-button--autoplay-next.vjs-button[aria-checked=true] {
|
.vjs-button--autoplay-next.vjs-button[aria-checked='true'] {
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vjs-button--autoplay-next.vjs-button::after {
|
.vjs-button--autoplay-next.vjs-button::after {
|
||||||
content: "";
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
@ -193,7 +193,7 @@
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vjs-button--autoplay-next.vjs-button[aria-checked=true]::after {
|
.vjs-button--autoplay-next.vjs-button[aria-checked='true']::after {
|
||||||
transform: translateX(12px);
|
transform: translateX(12px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -544,12 +544,6 @@
|
||||||
.button--highlighted {
|
.button--highlighted {
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.button--emoji {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
border-radius: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button__content {
|
.button__content {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
82
ui/scss/component/_comment-create.scss
Normal file
82
ui/scss/component/_comment-create.scss
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
@import '../init/vars';
|
||||||
|
|
||||||
|
$thumbnailWidth: 1.5rem;
|
||||||
|
$thumbnailWidthSmall: 1rem;
|
||||||
|
|
||||||
|
.comment__create {
|
||||||
|
font-size: var(--font-small);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
fieldset-section,
|
||||||
|
.form-field--SimpleMDE {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field__two-column {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment__create--reply {
|
||||||
|
margin-top: var(--spacing-m);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content_comment {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment__create--nested-reply {
|
||||||
|
margin-top: var(--spacing-s);
|
||||||
|
margin-left: calc((#{$thumbnailWidthSmall} + var(--spacing-xs)) * 2 + var(--spacing-m) + 4px);
|
||||||
|
|
||||||
|
@media (min-width: $breakpoint-small) {
|
||||||
|
margin-left: calc((#{$thumbnailWidth} + var(--spacing-m)) * 2 + var(--spacing-m) + 4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment__create--bottom {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-new__label-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
@media (min-width: $breakpoint-small) {
|
||||||
|
fieldset-section {
|
||||||
|
max-width: 10rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-new__label {
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-right: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment__sc-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: var(--spacing-s);
|
||||||
|
margin: var(--spacing-s) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment__sc-preview-amount {
|
||||||
|
margin-right: var(--spacing-m);
|
||||||
|
font-size: var(--font-large);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment--min-amount-notice {
|
||||||
|
.icon {
|
||||||
|
margin-bottom: -3px; // TODO fix few instances of these (find "-2px")
|
||||||
|
}
|
||||||
|
}
|
|
@ -32,29 +32,6 @@ $thumbnailWidthSmall: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment__create {
|
|
||||||
font-size: var(--font-small);
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
fieldset-section,
|
|
||||||
.form-field--SimpleMDE {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-field__two-column {
|
|
||||||
column-count: 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment__create--reply {
|
|
||||||
margin-top: var(--spacing-m);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment__create--bottom {
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment {
|
.comment {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -90,10 +67,6 @@ $thumbnailWidthSmall: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.content_comment {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment__thumbnail-wrapper {
|
.comment__thumbnail-wrapper {
|
||||||
flex: 0;
|
flex: 0;
|
||||||
margin-top: var(--spacing-xxs);
|
margin-top: var(--spacing-xxs);
|
||||||
|
@ -136,24 +109,10 @@ $thumbnailWidthSmall: 1rem;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment__sc-preview {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
padding: var(--spacing-s);
|
|
||||||
margin: var(--spacing-s) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment__edit-input {
|
.comment__edit-input {
|
||||||
margin-top: var(--spacing-xxs);
|
margin-top: var(--spacing-xxs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment__sc-preview-amount {
|
|
||||||
margin-right: var(--spacing-m);
|
|
||||||
font-size: var(--font-large);
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment__threadline {
|
.comment__threadline {
|
||||||
@extend .button--alt;
|
@extend .button--alt;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
@ -173,26 +132,6 @@ $thumbnailWidthSmall: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-new__label-wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: baseline;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
@media (min-width: $breakpoint-small) {
|
|
||||||
fieldset-section {
|
|
||||||
max-width: 10rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-new__label {
|
|
||||||
white-space: nowrap;
|
|
||||||
margin-right: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment--highlighted {
|
.comment--highlighted {
|
||||||
background: var(--color-comment-highlighted);
|
background: var(--color-comment-highlighted);
|
||||||
box-shadow: 0 0 0 5px var(--color-comment-highlighted);
|
box-shadow: 0 0 0 5px var(--color-comment-highlighted);
|
||||||
|
@ -432,8 +371,7 @@ $thumbnailWidthSmall: 1rem;
|
||||||
@extend .comment__action;
|
@extend .comment__action;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment__action--nested,
|
.comment__action--nested {
|
||||||
.comment__create--nested-reply {
|
|
||||||
margin-top: var(--spacing-s);
|
margin-top: var(--spacing-s);
|
||||||
margin-left: calc((#{$thumbnailWidthSmall} + var(--spacing-xs)) * 2 + var(--spacing-m) + 4px);
|
margin-left: calc((#{$thumbnailWidthSmall} + var(--spacing-xs)) * 2 + var(--spacing-m) + 4px);
|
||||||
|
|
||||||
|
@ -488,12 +426,6 @@ $thumbnailWidthSmall: 1rem;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment--min-amount-notice {
|
|
||||||
.icon {
|
|
||||||
margin-bottom: -3px; // TODO fix few instances of these (find "-2px")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.comments-own {
|
.comments-own {
|
||||||
.section__actions {
|
.section__actions {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|
40
ui/scss/component/_emote-selector.scss
Normal file
40
ui/scss/component/_emote-selector.scss
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
@import '../init/vars';
|
||||||
|
|
||||||
|
.emoteSelector {
|
||||||
|
animation: menu-animate-in var(--animation-duration) var(--animation-style);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
margin-bottom: var(--spacing-m);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoteSelector__list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-x: hidden;
|
||||||
|
max-height: 25vh;
|
||||||
|
padding: var(--spacing-s);
|
||||||
|
|
||||||
|
.emoteSelector__listRowItems {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.button--file-action {
|
||||||
|
margin: var(--spacing-xxs);
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
|
||||||
|
.button__content {
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
align-content: center;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
|
||||||
|
span {
|
||||||
|
margin: auto;
|
||||||
|
font-size: var(--font-large);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -440,9 +440,7 @@ fieldset-group {
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-field__quick-action {
|
.form-field__quick-action {
|
||||||
float: right;
|
|
||||||
font-size: var(--font-xsmall);
|
font-size: var(--font-xsmall);
|
||||||
margin-top: 2.5%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-field__textarea-info {
|
.form-field__textarea-info {
|
||||||
|
@ -454,12 +452,6 @@ fieldset-group {
|
||||||
margin-bottom: var(--spacing-s);
|
margin-bottom: var(--spacing-s);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-field__quick-emojis {
|
|
||||||
> *:not(:last-child) {
|
|
||||||
margin-right: var(--spacing-s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fieldset-section {
|
fieldset-section {
|
||||||
.form-field__internal-option {
|
.form-field__internal-option {
|
||||||
margin-top: var(--spacing-s);
|
margin-top: var(--spacing-s);
|
||||||
|
|
125
ui/util/remark-emote.js
Normal file
125
ui/util/remark-emote.js
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
import { EMOTES_24px as EMOTES } from 'constants/emotes';
|
||||||
|
import visit from 'unist-util-visit';
|
||||||
|
|
||||||
|
const EMOTE_NODE_TYPE = 'emote';
|
||||||
|
const RE_EMOTE = /:\+1:|:-1:|:[\w-]+:/;
|
||||||
|
|
||||||
|
// ***************************************************************************
|
||||||
|
// Tokenize emote
|
||||||
|
// ***************************************************************************
|
||||||
|
|
||||||
|
function findNextEmote(value, fromIndex, strictlyFromIndex) {
|
||||||
|
let begin = 0;
|
||||||
|
|
||||||
|
while (begin < value.length) {
|
||||||
|
const match = value.substring(begin).match(RE_EMOTE);
|
||||||
|
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
match.index += begin;
|
||||||
|
|
||||||
|
if (strictlyFromIndex && match.index !== fromIndex) {
|
||||||
|
if (match.index > fromIndex) {
|
||||||
|
// Already gone past desired index. Skip the rest.
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
// Next match might fit 'fromIndex'.
|
||||||
|
begin = match.index + match[0].length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromIndex > 0 && fromIndex > match.index && fromIndex < match.index + match[0].length) {
|
||||||
|
// Skip previously-rejected word
|
||||||
|
// This assumes that a non-zero 'fromIndex' means that a previous lookup has failed.
|
||||||
|
begin = match.index + match[0].length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const str = match[0];
|
||||||
|
|
||||||
|
if (EMOTES.some(({ name }) => str.toUpperCase() === name)) {
|
||||||
|
// Profit!
|
||||||
|
return { text: str, index: match.index };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strictlyFromIndex && match.index >= fromIndex) {
|
||||||
|
return null; // Since it failed and we've gone past the desired index, skip the rest.
|
||||||
|
}
|
||||||
|
|
||||||
|
begin = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function locateEmote(value, fromIndex) {
|
||||||
|
const emote = findNextEmote(value, fromIndex, false);
|
||||||
|
return emote ? emote.index : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate 'emote' markdown node
|
||||||
|
const createEmoteNode = (text) => ({
|
||||||
|
type: EMOTE_NODE_TYPE,
|
||||||
|
value: text,
|
||||||
|
children: [{ type: 'text', value: text }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate a markdown image from emote
|
||||||
|
function tokenizeEmote(eat, value, silent) {
|
||||||
|
if (silent) return true;
|
||||||
|
|
||||||
|
const emote = findNextEmote(value, 0, true);
|
||||||
|
if (emote) {
|
||||||
|
try {
|
||||||
|
const text = emote.text;
|
||||||
|
return eat(text)(createEmoteNode(text));
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenizeEmote.locator = locateEmote;
|
||||||
|
|
||||||
|
export function inlineEmote() {
|
||||||
|
const Parser = this.Parser;
|
||||||
|
const tokenizers = Parser.prototype.inlineTokenizers;
|
||||||
|
const methods = Parser.prototype.inlineMethods;
|
||||||
|
|
||||||
|
// Add an inline tokenizer (defined in the following example).
|
||||||
|
tokenizers.emote = tokenizeEmote;
|
||||||
|
|
||||||
|
// Run it just before `text`.
|
||||||
|
methods.splice(methods.indexOf('text'), 0, 'emote');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ***************************************************************************
|
||||||
|
// Format emote
|
||||||
|
// ***************************************************************************
|
||||||
|
|
||||||
|
const transformer = (node, index, parent) => {
|
||||||
|
if (node.type === EMOTE_NODE_TYPE && parent && parent.type === 'paragraph') {
|
||||||
|
const emoteStr = node.value;
|
||||||
|
const emote = EMOTES.find(({ name }) => emoteStr.toUpperCase() === name);
|
||||||
|
|
||||||
|
node.type = 'image';
|
||||||
|
node.url = emote.url;
|
||||||
|
node.title = emoteStr;
|
||||||
|
node.children = [{ type: 'text', value: emoteStr }];
|
||||||
|
if (!node.data || !node.data.hProperties) {
|
||||||
|
// Create new node data
|
||||||
|
node.data = {
|
||||||
|
hProperties: { emote: true },
|
||||||
|
};
|
||||||
|
} else if (node.data.hProperties) {
|
||||||
|
// Don't overwrite current attributes
|
||||||
|
node.data.hProperties = {
|
||||||
|
emote: true,
|
||||||
|
...node.data.hProperties,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const transform = (tree) => visit(tree, [EMOTE_NODE_TYPE], transformer);
|
||||||
|
|
||||||
|
export const formattedEmote = () => transform;
|
|
@ -440,9 +440,7 @@ fieldset-group {
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-field__quick-action {
|
.form-field__quick-action {
|
||||||
float: right;
|
|
||||||
font-size: var(--font-xsmall);
|
font-size: var(--font-xsmall);
|
||||||
margin-top: 2.5%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-field__textarea-info {
|
.form-field__textarea-info {
|
||||||
|
|
Loading…
Reference in a new issue