[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
|
||||
import 'scss/component/_comment-create.scss';
|
||||
import { FF_MAX_CHARS_IN_COMMENT, FF_MAX_CHARS_IN_LIVESTREAM_COMMENT } from 'constants/form-field';
|
||||
import { FormField, Form } from 'component/common/form';
|
||||
import { getChannelIdFromClaim } from 'util/claim';
|
||||
|
@ -13,6 +14,7 @@ import ChannelMentionSuggestions from 'component/channelMentionSuggestions';
|
|||
import ChannelThumbnail from 'component/channelThumbnail';
|
||||
import classnames from 'classnames';
|
||||
import CreditAmount from 'component/common/credit-amount';
|
||||
import EmoteSelector from './emote-selector';
|
||||
import Empty from 'component/common/empty';
|
||||
import I18nMessage from 'component/i18nMessage';
|
||||
import Icon from 'component/common/icon';
|
||||
|
@ -106,6 +108,7 @@ export function CommentCreate(props: Props) {
|
|||
const [deletedComment, setDeletedComment] = React.useState(false);
|
||||
const [pauseQuickSend, setPauseQuickSend] = React.useState(false);
|
||||
const [shouldDisableReviewButton, setShouldDisableReviewButton] = React.useState();
|
||||
const [showEmotes, setShowEmotes] = React.useState(false);
|
||||
|
||||
const selectedMentionIndex =
|
||||
commentValue.indexOf('@', selectionIndex) === selectionIndex
|
||||
|
@ -205,12 +208,6 @@ export function CommentCreate(props: Props) {
|
|||
window.removeEventListener('keydown', altEnterListener);
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (activeChannelClaim && commentValue.length) {
|
||||
handleCreateComment();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSupportComment() {
|
||||
if (!activeChannelClaim) {
|
||||
return;
|
||||
|
@ -368,6 +365,7 @@ export function CommentCreate(props: Props) {
|
|||
* @param {string} [environment] Optional environment for Stripe (test|live)
|
||||
*/
|
||||
function handleCreateComment(txid, payment_intent_id, environment) {
|
||||
setShowEmotes(false);
|
||||
setIsSubmitting(true);
|
||||
|
||||
createComment(commentValue, claimId, parentId, txid, payment_intent_id, environment)
|
||||
|
@ -509,13 +507,20 @@ export function CommentCreate(props: Props) {
|
|||
|
||||
return (
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
className={classnames('comment__create', {
|
||||
'comment__create--reply': isReply,
|
||||
'comment__create--nested-reply': isNested,
|
||||
'comment__create--bottom': bottom,
|
||||
})}
|
||||
>
|
||||
{showEmotes && (
|
||||
<EmoteSelector
|
||||
commentValue={commentValue}
|
||||
setCommentValue={setCommentValue}
|
||||
closeSelector={() => setShowEmotes(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!advancedEditor && (
|
||||
<ChannelMentionSuggestions
|
||||
uri={uri}
|
||||
|
@ -544,6 +549,7 @@ export function CommentCreate(props: Props) {
|
|||
!SIMPLE_SITE && (isReply ? undefined : advancedEditor ? __('Simple Editor') : __('Advanced Editor'))
|
||||
}
|
||||
quickActionHandler={() => !SIMPLE_SITE && setAdvancedEditor(!advancedEditor)}
|
||||
openEmoteMenu={() => setShowEmotes(!showEmotes)}
|
||||
onFocus={onTextareaFocus}
|
||||
onBlur={onTextareaBlur}
|
||||
placeholder={__('Say something about this...')}
|
||||
|
@ -601,6 +607,7 @@ export function CommentCreate(props: Props) {
|
|||
: __('Comment --[button to submit something]--')
|
||||
}
|
||||
requiresAuth={IS_WEB}
|
||||
onClick={() => activeChannelClaim && commentValue.length && handleCreateComment()}
|
||||
/>
|
||||
)}
|
||||
{!supportDisabled && !claimIsMine && (
|
||||
|
|
|
@ -1,33 +1,23 @@
|
|||
// @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 ReactDOMServer from 'react-dom/server';
|
||||
import SimpleMDE from 'react-simplemde-editor';
|
||||
import MarkdownPreview from 'component/common/markdown-preview';
|
||||
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'),
|
||||
];
|
||||
import type { ElementRef, Node } from 'react';
|
||||
|
||||
type Props = {
|
||||
name: string,
|
||||
label?: string | Node,
|
||||
render?: () => React$Node,
|
||||
prefix?: string,
|
||||
postfix?: string,
|
||||
error?: string | boolean,
|
||||
helper?: string | React$Node,
|
||||
type?: string,
|
||||
onChange?: (any) => any,
|
||||
defaultValue?: string | number,
|
||||
placeholder?: string | number,
|
||||
children?: React$Node,
|
||||
|
@ -43,18 +33,17 @@ type Props = {
|
|||
min?: number,
|
||||
max?: number,
|
||||
quickActionLabel?: string,
|
||||
quickActionHandler?: (any) => any,
|
||||
disabled?: boolean,
|
||||
onChange: (any) => void,
|
||||
value?: string | number,
|
||||
noEmojis?: boolean,
|
||||
render?: () => React$Node,
|
||||
onChange?: (any) => any,
|
||||
quickActionHandler?: (any) => any,
|
||||
openEmoteMenu?: () => void,
|
||||
};
|
||||
|
||||
export class FormField extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
labelOnLeft: false,
|
||||
blockWrap: true,
|
||||
};
|
||||
static defaultProps = { labelOnLeft: false, blockWrap: true };
|
||||
|
||||
input: { current: ElementRef<any> };
|
||||
|
||||
|
@ -67,14 +56,11 @@ export class FormField extends React.PureComponent<Props> {
|
|||
const { autoFocus } = this.props;
|
||||
const input = this.input.current;
|
||||
|
||||
if (input && autoFocus) {
|
||||
input.focus();
|
||||
}
|
||||
if (input && autoFocus) input.focus();
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
render,
|
||||
label,
|
||||
prefix,
|
||||
postfix,
|
||||
|
@ -92,11 +78,24 @@ export class FormField extends React.PureComponent<Props> {
|
|||
charCount,
|
||||
textAreaMaxLength,
|
||||
quickActionLabel,
|
||||
quickActionHandler,
|
||||
noEmojis,
|
||||
render,
|
||||
quickActionHandler,
|
||||
openEmoteMenu,
|
||||
...inputProps
|
||||
} = this.props;
|
||||
|
||||
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
|
||||
? ({ children: innerChildren }) => <fieldset-section class="radio">{innerChildren}</fieldset-section>
|
||||
: ({ children: innerChildren }) => <span className="radio">{innerChildren}</span>;
|
||||
|
@ -108,207 +107,164 @@ export class FormField extends React.PureComponent<Props> {
|
|||
</div>
|
||||
) : null;
|
||||
|
||||
let input;
|
||||
if (type) {
|
||||
if (type === 'radio') {
|
||||
input = (
|
||||
<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 inputSimple = (type: string) => (
|
||||
<>
|
||||
<input id={name} type={type} {...inputProps} />
|
||||
<label htmlFor={name}>{label}</label>
|
||||
</>
|
||||
);
|
||||
|
||||
const getInstance = (editor) => {
|
||||
// SimpleMDE max char check
|
||||
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 (delta <= 0) {
|
||||
return;
|
||||
}
|
||||
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'));
|
||||
}
|
||||
}
|
||||
});
|
||||
const inputSelect = (selectClass: string) => (
|
||||
<fieldset-section class={selectClass}>
|
||||
{(label || errorMessage) && (
|
||||
<label htmlFor={name}>{errorMessage ? <span className="error__text">{errorMessage}</span> : label}</label>
|
||||
)}
|
||||
<select id={name} {...inputProps}>
|
||||
{children}
|
||||
</select>
|
||||
</fieldset-section>
|
||||
);
|
||||
|
||||
// "Create Link (Ctrl-K)": highlight URL instead of label:
|
||||
editor.codemirror.on('changes', (instance, changes) => {
|
||||
try {
|
||||
// Grab the last change from the buffered list. I assume the
|
||||
// buffered one ('changes', instead of 'change') is more efficient,
|
||||
// and that "Create Link" will always end up last in the list.
|
||||
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://)';
|
||||
const input = () => {
|
||||
switch (type) {
|
||||
case 'radio':
|
||||
return <Wrapper>{inputSimple('radio')}</Wrapper>;
|
||||
case 'checkbox':
|
||||
return <div className="checkbox">{inputSimple('checkbox')}</div>;
|
||||
case 'range':
|
||||
return <div>{inputSimple('range')}</div>;
|
||||
case 'select':
|
||||
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
|
||||
// last text in the array to also cover the multi-line case:
|
||||
const urlLineText = lastChange.text[lastChange.text.length - 1];
|
||||
const getInstance = (editor) => {
|
||||
// SimpleMDE max char check
|
||||
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) {
|
||||
const from = lastChange.from;
|
||||
const to = lastChange.to;
|
||||
const isSelectionMultiline = lastChange.text.length > 1;
|
||||
const baseIndex = isSelectionMultiline ? 0 : from.ch;
|
||||
if (delta <= 0) return;
|
||||
|
||||
// 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);
|
||||
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'));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Do nothing (revert to original behavior)
|
||||
}
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
// 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>
|
||||
);
|
||||
// "Create Link (Ctrl-K)": highlight URL instead of label:
|
||||
editor.codemirror.on('changes', (instance, changes) => {
|
||||
try {
|
||||
// Grab the last change from the buffered list. I assume the
|
||||
// buffered one ('changes', instead of 'change') is more efficient,
|
||||
// and that "Create Link" will always end up last in the list.
|
||||
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 = (
|
||||
<div className="form-field--SimpleMDE" onContextMenu={stopContextMenu}>
|
||||
// The URL placeholder is always placed last, so just look at the
|
||||
// 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>
|
||||
<div className="form-field__two-column">
|
||||
<div>
|
||||
{(label || quickAction) && (
|
||||
<div className="form-field__two-column">
|
||||
<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>
|
||||
);
|
||||
} 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 },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{quickAction}
|
||||
</div>
|
||||
)}
|
||||
{countInfo}
|
||||
</div>
|
||||
</fieldset-section>
|
||||
);
|
||||
} else {
|
||||
const inputElement = <input type={type} id={name} {...inputProps} ref={this.input} />;
|
||||
const inner = inputButton ? (
|
||||
<input-submit>
|
||||
{inputElement}
|
||||
{inputButton}
|
||||
</input-submit>
|
||||
) : (
|
||||
inputElement
|
||||
);
|
||||
<textarea
|
||||
type={type}
|
||||
id={name}
|
||||
maxLength={textAreaMaxLength || FF_MAX_CHARS_DEFAULT}
|
||||
ref={this.input}
|
||||
{...inputProps}
|
||||
/>
|
||||
<div className="form-field__textarea-info">
|
||||
{!noEmojis && (
|
||||
<Button
|
||||
type="alt"
|
||||
className="button--file-action"
|
||||
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 = (
|
||||
<React.Fragment>
|
||||
return (
|
||||
<fieldset-section>
|
||||
{(label || errorMessage) && (
|
||||
<label htmlFor={name}>
|
||||
|
@ -318,17 +274,15 @@ export class FormField extends React.PureComponent<Props> {
|
|||
{prefix && <label htmlFor={name}>{prefix}</label>}
|
||||
{inner}
|
||||
</fieldset-section>
|
||||
</React.Fragment>
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{input}
|
||||
|
||||
<>
|
||||
{type && input()}
|
||||
{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" />
|
||||
</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 { formattedLinks, inlineLinks } from 'util/remark-lbry';
|
||||
import { formattedTimestamp, inlineTimestamp } from 'util/remark-timestamp';
|
||||
import { formattedEmote, inlineEmote } from 'util/remark-emote';
|
||||
import ZoomableImage from 'component/zoomableImage';
|
||||
import { CHANNEL_STAKED_LEVEL_VIDEO_COMMENTS, SIMPLE_SITE } from 'config';
|
||||
import Button from 'component/button';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import OptimizedImage from 'component/optimizedImage';
|
||||
|
||||
const RE_EMOTE = /:\+1:|:-1:|:[\w-]+:/;
|
||||
|
||||
type SimpleTextProps = {
|
||||
children?: React.Node,
|
||||
|
@ -94,10 +98,15 @@ const SimpleLink = (props: SimpleLinkProps) => {
|
|||
|
||||
const SimpleImageLink = (props: ImageLinkProps) => {
|
||||
const { src, title, alt, helpText } = props;
|
||||
|
||||
if (!src) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (title && RE_EMOTE.test(title) && src.includes('static.odycdn.com/emoticons')) {
|
||||
return <OptimizedImage title={title} src={src} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
button="link"
|
||||
|
@ -248,6 +257,8 @@ const MarkdownPreview = (props: MarkdownProps) => {
|
|||
.use(disableTimestamps || isMarkdownPost ? null : inlineTimestamp)
|
||||
.use(disableTimestamps || isMarkdownPost ? null : formattedTimestamp)
|
||||
// Emojis
|
||||
.use(inlineEmote)
|
||||
.use(formattedEmote)
|
||||
.use(remarkEmoji)
|
||||
// Render new lines without needing spaces.
|
||||
.use(remarkBreaks)
|
||||
|
|
|
@ -10,10 +10,11 @@ function scaleToDevicePixelRatio(value: number, window: any) {
|
|||
type Props = {
|
||||
src: string,
|
||||
objectFit?: string,
|
||||
waitLoad?: boolean,
|
||||
};
|
||||
|
||||
function OptimizedImage(props: Props) {
|
||||
const { objectFit, src, ...imgProps } = props;
|
||||
const { objectFit, src, waitLoad, ...imgProps } = props;
|
||||
const [optimizedSrc, setOptimizedSrc] = React.useState('');
|
||||
const ref = React.useRef<any>();
|
||||
|
||||
|
@ -101,8 +102,12 @@ function OptimizedImage(props: Props) {
|
|||
<img
|
||||
ref={ref}
|
||||
{...imgProps}
|
||||
style={{ visibility: waitLoad ? 'hidden' : 'visible' }}
|
||||
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 MYSTERIES = 'Mysteries';
|
||||
export const TECHNOLOGY = 'Technology';
|
||||
export const EMOJI = 'Emoji';
|
||||
|
|
|
@ -176,12 +176,12 @@
|
|||
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);
|
||||
}
|
||||
|
||||
.vjs-button--autoplay-next.vjs-button::after {
|
||||
content: "";
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
@ -193,7 +193,7 @@
|
|||
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);
|
||||
}
|
||||
|
||||
|
@ -544,12 +544,6 @@
|
|||
.button--highlighted {
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.button--emoji {
|
||||
font-size: 1.1rem;
|
||||
border-radius: 3rem;
|
||||
}
|
||||
|
||||
.button__content {
|
||||
display: flex;
|
||||
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 {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
@ -90,10 +67,6 @@ $thumbnailWidthSmall: 1rem;
|
|||
}
|
||||
}
|
||||
|
||||
.content_comment {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.comment__thumbnail-wrapper {
|
||||
flex: 0;
|
||||
margin-top: var(--spacing-xxs);
|
||||
|
@ -136,24 +109,10 @@ $thumbnailWidthSmall: 1rem;
|
|||
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 {
|
||||
margin-top: var(--spacing-xxs);
|
||||
}
|
||||
|
||||
.comment__sc-preview-amount {
|
||||
margin-right: var(--spacing-m);
|
||||
font-size: var(--font-large);
|
||||
}
|
||||
|
||||
.comment__threadline {
|
||||
@extend .button--alt;
|
||||
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 {
|
||||
background: var(--color-comment-highlighted);
|
||||
box-shadow: 0 0 0 5px var(--color-comment-highlighted);
|
||||
|
@ -432,8 +371,7 @@ $thumbnailWidthSmall: 1rem;
|
|||
@extend .comment__action;
|
||||
}
|
||||
|
||||
.comment__action--nested,
|
||||
.comment__create--nested-reply {
|
||||
.comment__action--nested {
|
||||
margin-top: var(--spacing-s);
|
||||
margin-left: calc((#{$thumbnailWidthSmall} + var(--spacing-xs)) * 2 + var(--spacing-m) + 4px);
|
||||
|
||||
|
@ -488,12 +426,6 @@ $thumbnailWidthSmall: 1rem;
|
|||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.comment--min-amount-notice {
|
||||
.icon {
|
||||
margin-bottom: -3px; // TODO fix few instances of these (find "-2px")
|
||||
}
|
||||
}
|
||||
|
||||
.comments-own {
|
||||
.section__actions {
|
||||
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 {
|
||||
float: right;
|
||||
font-size: var(--font-xsmall);
|
||||
margin-top: 2.5%;
|
||||
}
|
||||
|
||||
.form-field__textarea-info {
|
||||
|
@ -454,12 +452,6 @@ fieldset-group {
|
|||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
|
||||
.form-field__quick-emojis {
|
||||
> *:not(:last-child) {
|
||||
margin-right: var(--spacing-s);
|
||||
}
|
||||
}
|
||||
|
||||
fieldset-section {
|
||||
.form-field__internal-option {
|
||||
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 {
|
||||
float: right;
|
||||
font-size: var(--font-xsmall);
|
||||
margin-top: 2.5%;
|
||||
}
|
||||
|
||||
.form-field__textarea-info {
|
||||
|
|
Loading…
Reference in a new issue