Nice inputs and buttons (#510)

* Set default font in examle

* prettify file

* add ui elements

* Use UIButton at AuthPanel

* sort deps
* remove unsed getUserTitle method
* move UserId inside AuthPanel
* tests

* use UIButton and UInput in Anonymous login

* Use UIButton and UIInput in email login

* Replace Button to UIButton

* fix context for onTitleClick in dropdown
* rearrage class props in dropdown
* TODO: change finding over DOM to using ref

* Use UIButton in input

* rearrage deps

* Use UIButton in comment

* test

* Focus and Input buttons

* custom focus style for inputs and buttons
* the same focus style for comment input
* align buttons by top line in input

* Token dropdown & hover fix

* disable hover when button disabled
* make token dropdown markup in new style

* Fix trailing comma

* update prettier
* move prettier config to json format because it is more hendy for settings
(for example vscode can suggest rules)
* prettier fixed trailing comman in ejs

* Remove unnecessary conditions

* Files was formatted by Prettier

* fix cursor pointer on collapse button

* Move components and right naming

* ui-button -> button
* ui-input -> input
* input -> comment-form

* Change cursor behavior on disabled state

* Change button style

* font-weight: normal by default
* add small border-radius

* Change another one button to component

* Fix autofocus on username in email login form

* use preact inbuild autoFocus
* fix autofocus on back from token step
* sleep for 0s enough to wait next render before focus
* use more specific name for username input

* fix line-height at auth line on mobile

* fix className
This commit is contained in:
Pavel Mineev
2020-01-05 14:58:10 -06:00
committed by Umputun
parent aea7724769
commit 51d2310c1f
101 changed files with 1081 additions and 1113 deletions
+1
View File
@@ -19,6 +19,7 @@ module.exports = {
tsconfigRootDir: __dirname,
},
rules: {
'jsx-a11y/no-autofocus': 0,
// disabling because typescipt uses it's own lint (see next rule)
'no-unused-vars': 0,
// allow Rust-like var starting with _underscore
+15
View File
@@ -0,0 +1,15 @@
{
"printWidth": 120,
"useTabs": false,
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"bracketSpacing": true,
"arrowParens": "avoid",
"overrides": [{
"files": ["*.ejs"],
"options": {
"trailingComma": "none"
}
}]
}
-9
View File
@@ -1,9 +0,0 @@
module.exports = {
printWidth: 120,
useTabs: false,
semi: true,
singleQuote: true,
trailingComma: 'es5',
bracketSpacing: true,
arrowParens: 'avoid',
};
+1 -1
View File
@@ -2,7 +2,7 @@ declare module 'bem-react-helper' {
export interface Mods {
[key: string]: string | number | boolean | undefined | null;
}
export type Mix = string[] | string;
export type Mix = Array<string | undefined> | string;
export default function b(
classname: string,
props?: {
+1 -1
View File
@@ -105,7 +105,7 @@ export interface Config {
max_comment_size: number;
admins: string[];
admin_email: string;
auth_providers: (AuthProvider['name'])[];
auth_providers: AuthProvider['name'][];
low_score: number;
critical_score: number;
positive_score: boolean;
@@ -6,7 +6,7 @@
}
.auth-panel-anonymous-login-form__input {
width: 15em;
width: 9em;
}
.auth-panel-anonymous-login-form__remember-me {
@@ -14,16 +14,5 @@
}
.auth-panel-anonymous-login-form__submit {
background: none;
border: none;
padding: 0;
margin: 0 0.5em;
color: currentColor;
font: inherit;
cursor: pointer;
}
.auth-panel-anonymous-login-form__submit:disabled {
opacity: 0.4;
cursor: not-allowed;
margin-left: 0.5em;
}
@@ -3,6 +3,9 @@ import { createElement, Component, createRef } from 'preact';
import b from 'bem-react-helper';
import { Theme } from '@app/common/types';
import { Input } from '@app/components/input';
import { Button } from '@app/components/button';
interface Props {
onSubmit(username: string): Promise<void>;
theme: Theme;
@@ -76,10 +79,9 @@ export class AnonymousLoginForm extends Component<Props, State> {
return (
<form className={className} onSubmit={this.onSubmit}>
<input
className="auth-panel-anonymous-login-form__input"
<Input
ref={this.inputRef}
type="text"
mix="auth-panel-anonymous-login-form__input"
placeholder="Username"
value={this.state.inputValue}
onInput={this.onChange}
@@ -93,13 +95,16 @@ export class AnonymousLoginForm extends Component<Props, State> {
onChange={this.onCheckedChange}
checked={this.state.honeyPotValue}
/>
<input
className="auth-panel-anonymous-login-form__submit"
<Button
mix="auth-panel-anonymous-login-form__submit"
type="submit"
value="Log in"
kind="primary"
size="middle"
title={usernameInvalidReason || ''}
disabled={usernameInvalidReason !== null}
/>
>
Log in
</Button>
</form>
);
}
@@ -1,3 +0,0 @@
.auth-panel__dropdown-provider {
padding: 0.2rem 0.4rem;
}
@@ -13,35 +13,33 @@
.auth-panel-email-login-form__token-input {
resize: vertical;
font: inherit;
font-weight: normal;
border: 1px solid #c4c4c4;
padding: 4px;
font-family: inherit;
font-size: 0.8em;
font-weight: normal;
line-height: 1.5;
&:focus {
box-shadow: 0 0 0 2px rgba(37, 156, 154, 0.4);
border-color: #259c9a;
outline: none;
}
}
.auth-panel-email-login-form__submit {
background: none;
border: none;
padding: 0;
padding: 0.1em;
margin-top: 0.2em;
color: currentColor;
font: inherit;
cursor: pointer;
}
.auth-panel-email-login-form__submit:disabled {
opacity: 0.4;
cursor: not-allowed;
margin: 0.3rem 0.15rem 0.15rem;
}
.auth-panel-email-login-form__back-button {
color: #259c9a;
cursor: pointer;
text-align: left;
margin-left: 0.1rem;
margin-bottom: 0.5rem;
&:hover {
opacity: 0.8;
&:before {
content: '';
display: inline-block;
margin-right: 3px;
}
}
@@ -49,4 +47,5 @@
color: #9a0000;
text-align: center;
margin-top: 1em;
font-weight: normal;
}
@@ -8,7 +8,9 @@ import { sendEmailVerificationRequest } from '@app/common/api';
import { extractErrorMessageFromResponse } from '@app/utils/errorUtils';
import { getHandleClickProps } from '@app/common/accessibility';
import { sleep } from '@app/utils/sleep';
import TextareaAutosize from '@app/components/input/textarea-autosize';
import TextareaAutosize from '@app/components/comment-form/textarea-autosize';
import { Input } from '@app/components/input';
import { Button } from '@app/components/button';
const mapStateToProps = () => ({
sendEmailVerification: sendEmailVerificationRequest,
@@ -36,7 +38,7 @@ export class EmailLoginForm extends Component<Props, State> {
static usernameRegex = /^[a-zA-Z][\w ]+$/;
static emailRegex = /[^@]+@[^.]+\..+/;
inputRef = createRef<HTMLInputElement>();
usernameInputRef = createRef<HTMLInputElement>();
tokenRef = createRef<TextareaAutosize>();
constructor(props: Props) {
@@ -62,8 +64,8 @@ export class EmailLoginForm extends Component<Props, State> {
async focus() {
await sleep(100);
if (this.inputRef.current) {
this.inputRef.current.focus();
if (this.usernameInputRef.current) {
this.usernameInputRef.current.focus();
return;
}
this.tokenRef.current && this.tokenRef.current.textareaRef && this.tokenRef.current.textareaRef.select();
@@ -115,15 +117,23 @@ export class EmailLoginForm extends Component<Props, State> {
this.setState({ error: null, tokenValue: (e.target as HTMLInputElement).value });
}
goBack() {
async goBack() {
// Wait for finding back button in DOM by dropbox
// It prevents dropdown from closing, because if dropdown doesn't find clicked element it closes
await sleep(0);
this.setState({
tokenValue: '',
error: null,
verificationSent: false,
});
setTimeout(() => {
this.inputRef.current && this.inputRef.current.focus();
}, 100);
// Wait for rendering username+email step to find user input
await sleep(0);
if (this.usernameInputRef.current) {
this.usernameInputRef.current.focus();
}
}
getForm1InvalidReason(): string | null {
@@ -142,12 +152,6 @@ export class EmailLoginForm extends Component<Props, State> {
return null;
}
componentDidMount() {
setTimeout(() => {
this.inputRef.current && this.inputRef.current.focus();
}, 100);
}
render(props: Props) {
// TODO: will be great to `b` to accept `string | undefined | (string|undefined)[]` as classname
let className = b('auth-panel-email-login-form', {}, { theme: props.theme });
@@ -160,42 +164,31 @@ export class EmailLoginForm extends Component<Props, State> {
if (!this.state.verificationSent)
return (
<form className={className} onSubmit={this.onVerificationSubmit}>
{/*
* We adding hidden span element to bear with DropDown's onOutSideClick handler.
* This function checks if element that was clicked is a children of it's root component.
* And the problem is that by the time handler gets executed our target element is not a
* part of a dom, so handler suggests that we clicked somewhere outside and hides dropdown
*/}
<span
className="auth-panel-email-login-form__back-button"
role="button"
{...getHandleClickProps(this.goBack)}
style={{ display: 'none' }}
>
{'< Back'}
</span>
<input
className="auth-panel-email-login-form__input"
ref={this.inputRef}
type="text"
<Input
autoFocus
mix="auth-panel-email-login-form__input"
ref={this.usernameInputRef}
placeholder="Username"
value={this.state.usernameValue}
onInput={this.onUsernameChange}
/>
<input
className="auth-panel-email-login-form__input"
<Input
mix="auth-panel-email-login-form__input"
type="email"
placeholder="Email Address"
value={this.state.addressValue}
onInput={this.onAddressChange}
/>
<input
className="auth-panel-email-login-form__submit"
<Button
mix="auth-panel-email-login-form__submit"
kind="primary"
size="middle"
type="submit"
value="Send Verification"
title={form1InvalidReason || ''}
disabled={form1InvalidReason !== null}
/>
>
Send Verification
</Button>
{this.state.error && <div className="auth-panel-email-login-form__error">{this.state.error}</div>}
</form>
);
@@ -204,9 +197,9 @@ export class EmailLoginForm extends Component<Props, State> {
return (
<form className={className} onSubmit={this.onSubmit}>
<span className="auth-panel-email-login-form__back-button" role="button" {...getHandleClickProps(this.goBack)}>
{'< Back'}
</span>
<Button kind="link" mix="auth-panel-email-login-form__back-button" {...getHandleClickProps(this.goBack)}>
Back
</Button>
<TextareaAutosize
autofocus={true}
className="auth-panel-email-login-form__token-input"
@@ -217,13 +210,16 @@ export class EmailLoginForm extends Component<Props, State> {
spellcheck={false}
autocomplete="off"
/>
<input
className="auth-panel-email-login-form__submit"
<Button
mix="auth-panel-email-login-form__submit"
type="submit"
value="Confirm"
kind="primary"
size="middle"
title={form2InvalidReason || ''}
disabled={form2InvalidReason !== null}
/>
>
Confirm
</Button>
{this.state.error && <div className="auth-panel-email-login-form__error">{this.state.error}</div>}
</form>
);
@@ -1,13 +0,0 @@
.auth-panel__pseudo-link {
display: inline-block;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
/* important so "anonymous" label in Dropdown component
will not be overriden by `.dropdown_theme_light .dropdown__title` style */
color: #0aa !important;
&:hover {
color: #06c5c5 !important;
}
}
@@ -1,3 +0,0 @@
.auth-panel__sign-out {
margin-left: 5px;
}
@@ -1,6 +1,15 @@
.auth-panel__user-id {
overflow: hidden;
text-overflow: ellipsis;
padding: 5px 15px;
padding: 5px;
cursor: pointer;
}
.auth-panel__user-dropdown-title {
color: inherit;
&:hover {
color: inherit;
opacity: 0.9;
}
}
@@ -1,15 +0,0 @@
/** @jsx createElement */
import { createElement, JSX, FunctionComponent } from 'preact';
import b from 'bem-react-helper';
import { Theme } from '@app/common/types';
type Props = {
id: string;
theme: Theme;
} & JSX.HTMLAttributes;
export const UserID: FunctionComponent<Props> = ({ id, theme, ...props }) => (
<div {...props} className={b('auth-panel__user-id', {}, { theme })} title={id}>
{id}
</div>
);
@@ -1,3 +0,0 @@
export { UserID } from './auth-panel__user-id';
import './auth-panel__user-id.scss';
@@ -4,4 +4,5 @@
font-size: 14px;
line-height: 16px;
align-items: baseline;
padding: 2px 0;
}
@@ -1,8 +1,11 @@
/** @jsx createElement */
import { createElement } from 'preact';
import { mount } from 'enzyme';
import { Button } from '@app/components/button';
import { User, PostInfo } from '@app/common/types';
import { Props, AuthPanel } from './auth-panel';
import { User, PostInfo } from '../../common/types';
const DefaultProps: Partial<Props> = {
sort: '-score',
@@ -29,7 +32,7 @@ describe('<AuthPanel />', () => {
expect(authForm.text()).toEqual(expect.stringContaining('Sign in to comment using'));
const providerLinks = authForm.find('.auth-panel__pseudo-link');
const providerLinks = authForm.find(Button);
expect(providerLinks.at(0).text()).toEqual('Google');
expect(providerLinks.at(1).text()).toEqual('GitHub');
@@ -49,7 +52,7 @@ describe('<AuthPanel />', () => {
const providerLinks = element
.find('.auth-panel__column')
.first()
.find('.auth-panel__pseudo-link');
.find(Button);
expect(providerLinks.at(0).text()).toEqual('GitHub');
expect(providerLinks.at(1).text()).toEqual('Google');
@@ -69,7 +72,7 @@ describe('<AuthPanel />', () => {
const providerLinks = element
.find('.auth-panel__column')
.first()
.find('.auth-panel__pseudo-link');
.find(Button);
expect(providerLinks.at(0).text()).toEqual('Google');
expect(providerLinks.at(1).text()).toEqual('GitHub');
@@ -94,7 +97,7 @@ describe('<AuthPanel />', () => {
expect(authForm.text()).toEqual(expect.stringContaining('Sign in using Google or GitHub'));
const providerLinks = authForm.find('.auth-panel__pseudo-link');
const providerLinks = authForm.find(Button);
expect(providerLinks.at(0).text()).toEqual('Google');
expect(providerLinks.at(1).text()).toEqual('GitHub');
@@ -7,15 +7,15 @@ import { requestDeletion } from '@app/utils/email';
import { getHandleClickProps } from '@app/common/accessibility';
import { User, AuthProvider, Sorting, Theme, PostInfo } from '@app/common/types';
import Dropdown, { DropdownItem } from '@app/components/dropdown';
import { Button } from '@app/components/button';
import { UserID } from './__user-id';
import { AnonymousLoginForm } from './__anonymous-login-form';
import { EmailLoginFormConnected } from './__email-login-form';
import { StoreState } from '@app/store';
import { ProviderState } from '@app/store/provider/reducers';
import debounce from '@app/utils/debounce';
import postMessage from '@app/utils/postMessage';
import { StoreState } from '@app/store';
import { ProviderState } from '@app/store/provider/reducers';
import Dropdown, { DropdownItem } from '@app/components/dropdown';
import { Button } from '@app/components/button';
import { AnonymousLoginForm } from './__anonymous-login-form';
import { EmailLoginFormConnected } from './__email-login-form';
import { EmailLoginFormRef } from './__email-login-form/auth-panel__email-login-form';
export interface Props {
@@ -25,7 +25,7 @@ export interface Props {
isCommentsDisabled: boolean;
theme: Theme;
postInfo: PostInfo;
providers: (AuthProvider['name'])[];
providers: AuthProvider['name'][];
provider: ProviderState;
onSortChange(s: Sorting): Promise<void>;
@@ -129,11 +129,6 @@ export class AuthPanel extends Component<Props, State> {
}
}
getUserTitle() {
const { user } = this.props;
return <span className="auth-panel__username">{user!.name}</span>;
}
/** wrapper function to handle both oauth and anonymous providers*/
onSignIn(provider: AuthProvider) {
this.props.onSignIn(provider);
@@ -153,7 +148,7 @@ export class AuthPanel extends Component<Props, State> {
}
renderAuthorized = () => {
const { user, onSignOut } = this.props;
const { user, onSignOut, theme } = this.props;
if (!user) return null;
const isUserAnonymous = user && user.id.substr(0, 10) === 'anonymous_';
@@ -161,20 +156,26 @@ export class AuthPanel extends Component<Props, State> {
return (
<div className="auth-panel__column">
You signed in as{' '}
<Dropdown title={user.name} theme={this.props.theme}>
<Dropdown title={user.name} titleClass="auth-panel__user-dropdown-title" theme={theme}>
<DropdownItem separator={!isUserAnonymous}>
<UserID id={user.id} theme={this.props.theme} {...getHandleClickProps(this.toggleUserInfoVisibility)} />
<div
id={user.id}
className={b('auth-panel__user-id', {}, { theme })}
{...getHandleClickProps(this.toggleUserInfoVisibility)}
>
{user.id}
</div>
</DropdownItem>
{!isUserAnonymous && (
<DropdownItem>
<Button kind="link" theme={this.props.theme} onClick={() => requestDeletion().then(onSignOut)}>
<Button theme={theme} onClick={() => requestDeletion().then(onSignOut)}>
Request my data removal
</Button>
</DropdownItem>
)}
</Dropdown>{' '}
<Button className="auth-panel__sign-out" kind="link" theme={this.props.theme} onClick={onSignOut}>
<Button kind="link" theme={theme} onClick={onSignOut}>
Sign out?
</Button>
</div>
@@ -186,7 +187,7 @@ export class AuthPanel extends Component<Props, State> {
return (
<Dropdown
title={PROVIDER_NAMES['anonymous']}
titleClass={`${dropdown ? 'auth-panel__dropdown-provider' : ''} auth-panel__pseudo-link`}
titleClass={dropdown ? 'auth-panel__dropdown-provider' : ''}
theme={this.props.theme}
>
<DropdownItem>
@@ -203,7 +204,7 @@ export class AuthPanel extends Component<Props, State> {
return (
<Dropdown
title={PROVIDER_NAMES['email']}
titleClass={`${dropdown ? 'auth-panel__dropdown-provider' : ''} auth-panel__pseudo-link`}
titleClass={dropdown ? 'auth-panel__dropdown-provider' : ''}
theme={this.props.theme}
onTitleClick={this.onEmailTitleClick}
>
@@ -220,25 +221,21 @@ export class AuthPanel extends Component<Props, State> {
}
return (
<span
className={`${dropdown ? 'auth-panel__dropdown-provider' : ''} auth-panel__pseudo-link`}
<Button
mix={dropdown ? 'auth-panel__dropdown-provider' : ''}
kind="link"
data-provider={provider}
{...getHandleClickProps(this.handleOAuthLogin)}
role="link"
>
{PROVIDER_NAMES[provider]}
</span>
</Button>
);
};
renderOther = (providers: (AuthProvider['name'])[]) => {
renderOther = (providers: AuthProvider['name'][]) => {
return (
<Dropdown
title="Other"
titleClass="auth-panel__pseudo-link"
theme={this.props.theme}
onTitleClick={this.onEmailTitleClick}
>
<Dropdown title="Other" theme={this.props.theme} onTitleClick={this.onEmailTitleClick}>
{providers.map(provider => (
<DropdownItem>{this.renderProvider(provider, true)}</DropdownItem>
))}
@@ -323,26 +320,28 @@ export class AuthPanel extends Component<Props, State> {
renderSettingsLabel = () => {
return (
<span
className="auth-panel__pseudo-link auth-panel__admin-action"
<Button
kind="link"
mix="auth-panel__admin-action"
{...getHandleClickProps(() => this.toggleBlockedVisibility())}
role="link"
>
{this.state.isBlockedVisible ? 'Hide' : 'Show'} settings
</span>
</Button>
);
};
renderReadOnlySwitch = () => {
const { isCommentsDisabled } = this.props;
return (
<span
className="auth-panel__pseudo-link auth-panel__admin-action"
<Button
kind="link"
mix="auth-panel__admin-action"
{...getHandleClickProps(() => this.toggleCommentsAvailability())}
role="link"
>
{isCommentsDisabled ? 'Enable' : 'Disable'} comments
</span>
</Button>
);
};
@@ -5,18 +5,14 @@ import './auth-panel.scss';
import './__readonly-label/auth-panel__readonly-label.scss';
import './__column/auth-panel__column.scss';
import './__pseudo-link/auth-panel__pseudo-link.scss';
import './__select/auth-panel__select.scss';
import './__select-label/auth-panel__select-label.scss';
import './__select-label-value/auth-panel__select-label-value.scss';
import './__sort/auth-panel__sort.scss';
import './__user-id/auth-panel__user-id.scss';
import './__sign-out/auth-panel__sign-out.scss';
import './_theme/_dark/auth-panel_theme_dark.scss';
import './_theme/_light/auth-panel_theme_light.scss';
import './_logged-in/auth-panel_logged-in.scss';
import './__dropdown-provider/auth-panel__dropdown-provider.scss';
@@ -1,3 +0,0 @@
.button_focused:not(.button_clicked) {
outline-width: 5px;
}
@@ -1,10 +1,6 @@
.button_kind_link {
border: none;
background: transparent;
padding: 0;
font-weight: 700;
white-space: nowrap;
font-weight: bold;
color: #0aa;
&:hover {
@@ -0,0 +1,12 @@
.button_kind_primary {
background: #259c9a;
color: #fff;
&:hover {
background: #06c5c5;
}
&:hover&:disabled {
background: #259c9a;
}
}
@@ -0,0 +1,8 @@
.button_kind_secondary {
background: #fff;
color: inherit;
&:hover {
box-shadow: inset 0 0 0 2px #06c5c5;
}
}
@@ -1,5 +0,0 @@
.button_kind_text {
border: none;
background: transparent;
padding: 0;
}
@@ -0,0 +1,5 @@
.button_size_large {
height: 2rem;
padding: 0 12px;
font-size: 16px;
}
@@ -0,0 +1,4 @@
.button_size_middle {
height: 2rem;
padding: 0 12px;
}
@@ -0,0 +1,4 @@
.button_theme_dark.button_kind_secondary {
background: #22201c;
color: #ddd;
}
+18 -3
View File
@@ -1,6 +1,21 @@
.button {
cursor: pointer;
outline-width: 0;
font-size: inherit;
background: none;
border: 0;
padding: 0;
margin: 0;
border-radius: 2px;
font-family: inherit;
font-size: inherit;
cursor: pointer;
white-space: nowrap;
&:focus {
box-shadow: 0 0 0 2px rgba(37, 156, 154, 0.4);
outline: none;
}
&:disabled {
opacity: 0.4;
cursor: default;
}
}
+14 -95
View File
@@ -1,107 +1,26 @@
/** @jsx createElement */
import { createElement, JSX, Component } from 'preact';
import b from 'bem-react-helper';
import noop from '@app/utils/noop';
import { createElement, JSX } from 'preact';
import { forwardRef } from 'preact/compat';
import b, { Mods, Mix } from 'bem-react-helper';
import { Theme } from '@app/common/types';
type Props = {
interface Props extends Omit<JSX.HTMLAttributes, 'size'> {
kind?: 'primary' | 'secondary' | 'link';
size?: 'middle' | 'large';
theme?: Theme;
mods?: Mods;
mix?: Mix;
type?: string;
kind?: string;
theme: Theme;
mix?: string;
onClick?: (e: MouseEvent) => void;
onFocus?: (e: FocusEvent) => void;
onBlur?: (e: FocusEvent) => void;
} & JSX.HTMLAttributes;
interface State {
isClicked: boolean;
isFocused: boolean;
}
export class Button extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
isClicked: false,
isFocused: false,
};
this.onMouseDown = this.onMouseDown.bind(this);
this.onFocus = this.onFocus.bind(this);
this.onBlur = this.onBlur.bind(this);
}
onMouseDown() {
this.setState({
isClicked: true,
});
}
onClick(e: MouseEvent) {
this.props.onClick!(e);
}
onBlur(e: FocusEvent) {
this.setState({
isClicked: false,
isFocused: false,
});
this.props.onBlur!(e);
}
onFocus(e: FocusEvent) {
this.setState({
isFocused: true,
});
this.props.onFocus!(e);
}
render(props: Props, state: State) {
const { children, className } = props;
const { isClicked, isFocused } = state;
let rclassName = b(
'button',
{ mix: props.mix },
{ theme: props.theme, type: props.type, kind: props.kind, clicked: isClicked, focused: isFocused }
);
if (className) {
rclassName +=
' ' +
b(
className,
{},
{ theme: props.theme, type: props.type, kind: props.kind, clicked: isClicked, focused: isFocused }
);
}
const localProps = { ...props };
delete localProps.children;
delete localProps.mix;
export const Button = forwardRef<HTMLButtonElement, Props>(
({ children, theme, mods, mix, kind, type = 'button', size, ...props }) => {
const className = b('button', { mods: { kind, size }, mix }, { theme, ...mods });
return (
<button
{...localProps}
className={rclassName}
onMouseDown={this.onMouseDown}
onBlur={this.onBlur}
onFocus={this.onFocus}
>
<button className={className} type={type} {...props}>
{children}
</button>
);
}
}
Button.defaultProps = {
type: 'button',
onClick: noop,
onBlur: noop,
onFocus: noop,
};
);
+6 -2
View File
@@ -3,6 +3,10 @@ export { Button } from './button';
import './button.scss';
import './_kind/_link/button_kind_link.scss';
import './_kind/_text/button_kind_text.scss';
import './_kind/_primary/button_kind_primary.scss';
import './_kind/_secondary/button_kind_secondary.scss';
import './_focused/button_focused.scss';
import './_size/_large/button_size_large.scss';
import './_size/_middle/button_size_middle.scss';
import './_theme/_dark/button_theme_dark.scss';
@@ -1,4 +1,4 @@
.input__actions {
.comment-form__actions {
display: flex;
align-items: center;
padding-top: 8px;
@@ -1,4 +1,4 @@
.input__button_type_preview {
.comment-form__button_type_preview {
&:hover,
&:focus {
box-shadow: inset 0 0 0 2px #0aa;
@@ -1,4 +1,4 @@
.input__button_type_send {
.comment-form__button_type_send {
background: #259c9a;
color: #fff;
@@ -0,0 +1,4 @@
.comment-form__button {
margin: 8px 8px 0 0;
align-self: flex-start;
}
@@ -1,4 +1,4 @@
.input__control-panel {
.comment-form__control-panel {
height: 30px;
background-color: #eee;
}
@@ -1,4 +1,4 @@
.input__counter {
.comment-form__counter {
position: absolute;
right: 4px;
bottom: 4px;
@@ -1,4 +1,4 @@
.input__error {
.comment-form__error {
margin: 0;
padding: 10px 12px;
font-size: 14px;
@@ -0,0 +1,3 @@
.comment-form__field-wrapper {
position: relative;
}
@@ -1,4 +1,4 @@
.input__field {
.comment-form__field {
$lineHeight: 1.4;
$fontSize: 16px;
$paddingVrt: 10px;
@@ -20,7 +20,8 @@
transform: translateZ(0); /* let's try to fix blinking in Safari, again */
&:focus {
box-shadow: inset 0 0 0 2px #259c9a;
box-shadow: 0 0 0 2px rgba(37, 156, 154, 0.4);
border-color: #259c9a;
outline: none;
}
@@ -1,4 +1,4 @@
.input__markdown-link {
.comment-form__markdown-link {
font-weight: 700;
white-space: nowrap;
text-decoration: none;
@@ -1,9 +1,9 @@
.input__toolbar {
.comment-form__toolbar {
display: block;
float: right;
}
.input__toolbar-file-input {
.comment-form__toolbar-file-input {
position: absolute;
width: 1px;
height: 1px;
@@ -11,7 +11,7 @@
clip: rect(0 0 0 0);
}
.input__toolbar-item {
.comment-form__toolbar-item {
background: none;
border: 0;
color: #586069;
@@ -24,16 +24,16 @@
}
}
.input__toolbar-icon {
.comment-form__toolbar-icon {
display: inline-block;
fill: currentColor;
}
.input__toolbar-group {
.comment-form__toolbar-group {
display: inline-block;
margin-left: 20px;
}
.input__toolbar-group:first-child {
.comment-form__toolbar-group:first-child {
margin-left: 0;
}
@@ -0,0 +1,3 @@
.comment-form__markdown {
margin-bottom: 5px;
}
@@ -0,0 +1,3 @@
.comment-form__preview-wrapper {
overflow: hidden;
}
@@ -1,4 +1,4 @@
.input__preview {
.comment-form__preview {
margin-top: 8px;
padding: 7px 11px;
overflow: hidden;
@@ -1,4 +1,4 @@
.input__rss-link {
.comment-form__rss-link {
font-weight: 700;
white-space: nowrap;
text-decoration: none;
@@ -1,4 +1,4 @@
.input__rss {
.comment-form__rss {
margin-left: auto;
align-self: flex-end;
font-size: 12px;
@@ -1,3 +1,3 @@
.input_simple {
.comment-form_simple {
border-width: 12px;
}
@@ -1,49 +1,49 @@
.input_theme_dark {
.comment-form_theme_dark {
border-color: #333;
background: #22201c; /* try to fix textarea blinking in Safari */
.input__actions {
.comment-form__actions {
background: #333;
}
.input__button_type_preview {
.comment-form__button_type_preview {
background: #22201c;
color: #ddd;
}
.input__button_type_send {
.comment-form__button_type_send {
color: #ddd;
}
.input__error {
.comment-form__error {
border-top: 8px solid #333;
background: #672323;
color: #f98989;
}
.input__field {
.comment-form__field {
background: #22201c;
color: #eee;
}
.input__preview {
.comment-form__preview {
border-color: #22201c;
background: #22201c;
color: #ddd;
}
.input__preview-wrapper {
.comment-form__preview-wrapper {
background: #333;
}
.input__toolbar-item {
.comment-form__toolbar-item {
color: #ddd;
&:hover {
color: #0aa;
}
}
.input__control-panel {
.comment-form__control-panel {
background-color: #333;
}
}
@@ -1,38 +1,38 @@
.input_theme_light {
.comment-form_theme_light {
border-color: #eee;
background: #fff; /* try to fix textarea blinking in Safari */
.input__actions {
.comment-form__actions {
background: #eee;
}
.input__button_type_preview {
.comment-form__button_type_preview {
background: #fff;
color: #000;
}
.input__button_type_send {
.comment-form__button_type_send {
color: #fff;
}
.input__error {
.comment-form__error {
border-top: 8px solid #eee;
background: #ffd7d7;
color: #9a0000;
}
.input__field {
.comment-form__field {
background: #fff;
color: #000;
}
.input__preview {
.comment-form__preview {
border-color: #eee;
background: #fff;
color: #333;
}
.input__preview-wrapper {
.comment-form__preview-wrapper {
background: #eee;
}
}
@@ -0,0 +1,8 @@
.comment-form {
position: relative;
display: block;
font-size: 0;
border-style: solid;
border-width: 6px 12px 12px 12px;
border-radius: 2px;
}
@@ -0,0 +1,15 @@
/** @jsx createElement */
import { createElement } from 'preact';
import { shallow } from 'enzyme';
import { CommentForm, Props } from './comment-form';
describe('<CommentForm />', () => {
it('shoud render without control panel, preview button, and rss links in "simple view" mode', () => {
const element = shallow(<CommentForm {...({ simpleView: true } as Props)} />);
expect(element.exists('.comment-form__control-panel')).toEqual(false);
expect(element.exists('.comment-form__button_type_preview')).toEqual(false);
expect(element.exists('.comment-form__rss')).toEqual(false);
});
});
@@ -0,0 +1,453 @@
/** @jsx createElement */
import { createElement, Component, createRef } from 'preact';
import b, { Mix } from 'bem-react-helper';
import { User, Theme, Image, ApiError } from '@app/common/types';
import { BASE_URL, API_BASE } from '@app/common/constants';
import { StaticStore } from '@app/common/static_store';
import { siteId, url, pageTitle } from '@app/common/settings';
import { extractErrorMessageFromResponse } from '@app/utils/errorUtils';
import { sleep } from '@app/utils/sleep';
import { replaceSelection } from '@app/utils/replaceSelection';
import { Button } from '@app/components/button';
import MarkdownToolbar from './markdown-toolbar';
import TextareaAutosize from './textarea-autosize';
const RSS_THREAD_URL = `${BASE_URL}${API_BASE}/rss/post?site=${siteId}&url=${url}`;
const RSS_SITE_URL = `${BASE_URL}${API_BASE}/rss/site?site=${siteId}`;
const RSS_REPLIES_URL = `${BASE_URL}${API_BASE}/rss/reply?site=${siteId}&user=`;
let textareaId = 0;
export interface Props {
/** user id for rss link generation */
userId?: User['id'];
errorMessage?: string;
value?: string;
mix?: Mix;
mode?: 'main' | 'edit' | 'reply';
theme: Theme;
simpleView?: boolean;
autofocus?: boolean;
onSubmit(text: string, pageTitle: string): Promise<void>;
getPreview(text: string): Promise<string>;
/** action on cancel. optional as root input has no cancel option */
onCancel?: () => void;
uploadImage?: (image: File) => Promise<Image>;
}
interface State {
preview: string | null;
isErrorShown: boolean;
/** error message, if contains newlines, it will be splitted to multiple errors */
errorMessage: string | null;
/** prevents error hiding on input event */
errorLock: boolean;
isDisabled: boolean;
maxLength: number;
/** main input value */
text: string;
/** override main button text */
buttonText: null | string;
}
const Labels = {
main: 'Send',
edit: 'Save',
reply: 'Reply',
};
const ImageMimeRegex = /image\//i;
export class CommentForm extends Component<Props, State> {
/** reference to textarea element */
textAreaRef = createRef<TextareaAutosize>();
textareaId: string;
constructor(props: Props) {
super(props);
textareaId = textareaId + 1;
this.textareaId = `textarea_${textareaId}`;
this.state = {
preview: null,
isErrorShown: false,
errorMessage: null,
errorLock: false,
isDisabled: false,
maxLength: StaticStore.config.max_comment_size,
text: props.value || '',
buttonText: null,
};
this.send = this.send.bind(this);
this.getPreview = this.getPreview.bind(this);
this.onInput = this.onInput.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.onDragOver = this.onDragOver.bind(this);
this.onDrop = this.onDrop.bind(this);
this.appendError = this.appendError.bind(this);
this.uploadImage = this.uploadImage.bind(this);
this.uploadImages = this.uploadImages.bind(this);
this.onPaste = this.onPaste.bind(this);
}
componentWillReceiveProps(nextProps: Props) {
if (nextProps.value !== this.props.value) {
this.setState({ text: nextProps.value || '' });
this.props.autofocus && this.textAreaRef.current && this.textAreaRef.current.focus();
}
}
shouldComponentUpdate(nextProps: Props, nextState: State) {
return (
nextProps.mode !== this.props.mode ||
nextProps.theme !== this.props.theme ||
nextProps.userId !== this.props.userId ||
nextProps.value !== this.props.value ||
nextProps.errorMessage !== this.props.errorMessage ||
nextState !== this.state
);
}
onKeyDown(e: KeyboardEvent) {
// send on cmd+enter / ctrl+enter
if (e.keyCode === 13 && (e.metaKey || e.ctrlKey)) {
this.send(e);
}
}
onInput(e: Event) {
if (this.state.errorLock) {
this.setState({
preview: null,
text: (e.target as HTMLInputElement).value,
});
return;
}
this.setState({
isErrorShown: false,
errorMessage: null,
preview: null,
text: (e.target as HTMLInputElement).value,
});
}
async onPaste(e: ClipboardEvent) {
if (!(e.clipboardData && e.clipboardData.files.length > 0)) {
return;
}
e.preventDefault();
const files = Array.from(e.clipboardData.files);
await this.uploadImages(files);
}
send(e: Event) {
const text = this.state.text;
const props = this.props;
if (e) e.preventDefault();
if (!text || !text.trim()) return;
if (text === this.props.value) {
this.props.onCancel && this.props.onCancel();
this.setState({ preview: null, text: '' });
}
this.setState({ isDisabled: true, isErrorShown: false });
props
.onSubmit(text, pageTitle || document.title)
.then(() => {
this.setState({ preview: null, text: '' });
})
.catch(e => {
console.error(e); // eslint-disable-line no-console
const errorMessage = extractErrorMessageFromResponse(e);
this.setState({ isErrorShown: true, errorMessage });
})
.finally(() => this.setState({ isDisabled: false }));
}
getPreview() {
const text = this.state.text;
if (!text || !text.trim()) return;
this.setState({ isErrorShown: false, errorMessage: null });
this.props
.getPreview(text)
.then(preview => this.setState({ preview }))
.catch(() => {
this.setState({ isErrorShown: true, errorMessage: null });
});
}
/** appends error to input's error block */
appendError(...errors: string[]) {
if (!this.state.errorMessage) {
this.setState({
errorMessage: errors.join('\n'),
isErrorShown: true,
});
return;
}
this.setState({
errorMessage: this.state.errorMessage + '\n' + errors.join('\n'),
isErrorShown: true,
});
}
onDragOver(e: DragEvent) {
if (!this.props.uploadImage) return;
if (StaticStore.config.max_image_size === 0) return;
if (!this.textAreaRef) return;
if (!e.dataTransfer) return;
const items = Array.from(e.dataTransfer.items);
if (Array.from(items).filter(i => i.kind === 'file' && ImageMimeRegex.test(i.type)).length === 0) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}
onDrop(e: DragEvent) {
if (!this.props.uploadImage) return;
if (StaticStore.config.max_image_size === 0) return;
if (!e.dataTransfer) return;
const data = Array.from(e.dataTransfer.files).filter(f => ImageMimeRegex.test(f.type));
if (data.length === 0) return;
e.preventDefault();
this.uploadImages(data);
}
/** wrapper with error handling for props.uploadImage */
uploadImage(file: File): Promise<Image | Error> {
return this.props.uploadImage!(file).catch(
(e: ApiError | string) =>
new Error(
typeof e === 'string'
? `${file.name} upload failed with "${e}"`
: `${file.name} upload failed with "${e.error}"`
)
);
}
/** performs upload process */
async uploadImages(files: File[]) {
if (!this.props.uploadImage) return;
if (!this.textAreaRef.current) return;
/** Human readable image size limit, i.e 5MB */
const maxImageSizeString = (StaticStore.config.max_image_size / 1024 / 1024).toFixed(2) + 'MB';
/** upload delay to avoid server rate limiter */
const uploadDelay = 5000;
const isSelectionSupported = this.textAreaRef.current.isSelectionSupported();
this.setState({
errorLock: true,
errorMessage: null,
isErrorShown: false,
isDisabled: true,
buttonText: 'Uploading...',
});
// fallback for ie < 9
if (!isSelectionSupported) {
for (let i = 0; i < files.length; i++) {
const file = files[i];
const isFirst = i === 0;
const placeholderStart = this.state.text.length === 0 ? '' : '\n';
if (file.size > StaticStore.config.max_image_size) {
this.appendError(`${file.name} exceeds size limit of ${maxImageSizeString}`);
continue;
}
!isFirst && (await sleep(uploadDelay));
const result = await this.uploadImage(file);
if (result instanceof Error) {
this.appendError(result.message);
continue;
}
const markdownString = `${placeholderStart}![${result.name}](${result.url})`;
this.setState({
text: this.state.text + markdownString,
});
}
this.setState({ errorLock: false, isDisabled: false, buttonText: null });
return;
}
for (let i = 0; i < files.length; i++) {
const file = files[i];
const isFirst = i === 0;
const placeholderStart = this.state.text.length === 0 ? '' : '\n';
const uploadPlaceholder = `${placeholderStart}![uploading ${file.name}...]()`;
const uploadPlaceholderLength = uploadPlaceholder.length;
const selection = this.textAreaRef.current.getSelection();
/** saved selection in case of error */
const originalText = this.state.text;
const restoreSelection = async () => {
this.setState({
text: originalText,
});
/** sleeping awhile so textarea catch state change and its selection */
await sleep(100);
this.textAreaRef.current!.setSelection(selection);
};
if (file.size > StaticStore.config.max_image_size) {
this.appendError(`${file.name} exceeds size limit of ${maxImageSizeString}`);
continue;
}
this.setState({
text: replaceSelection(this.state.text, selection, uploadPlaceholder),
});
!isFirst && (await sleep(uploadDelay));
const result = await this.uploadImage(file);
if (result instanceof Error) {
this.appendError(result.message);
await restoreSelection();
continue;
}
const markdownString = `${placeholderStart}![${result.name}](${result.url})`;
this.setState({
text: replaceSelection(this.state.text, [selection[0], selection[0] + uploadPlaceholderLength], markdownString),
});
/** sleeping awhile so textarea catch state change and its selection */
await sleep(100);
const selectionPointer = selection[0] + markdownString.length;
this.textAreaRef.current.setSelection([selectionPointer, selectionPointer]);
}
this.setState({ errorLock: false, isDisabled: false, buttonText: null });
}
render(props: Props, { isDisabled, isErrorShown, errorMessage, preview, maxLength, text, buttonText }: State) {
const charactersLeft = maxLength - text.length;
errorMessage = props.errorMessage || errorMessage;
const label = buttonText || Labels[props.mode || 'main'];
return (
<form
className={b('comment-form', {
mods: {
theme: props.theme,
type: props.mode || 'reply',
simple: props.simpleView,
},
mix: props.mix,
})}
onSubmit={this.send}
aria-label="New comment"
onDragOver={this.onDragOver}
onDrop={this.onDrop}
>
{!props.simpleView && (
<div className="comment-form__control-panel">
<MarkdownToolbar
allowUpload={Boolean(this.props.uploadImage)}
uploadImages={this.uploadImages}
textareaId={this.textareaId}
/>
</div>
)}
<div className="comment-form__field-wrapper">
<TextareaAutosize
id={this.textareaId}
onPaste={this.onPaste}
ref={this.textAreaRef}
className="comment-form__field"
placeholder="Your comment here"
value={text}
maxLength={maxLength}
onInput={this.onInput}
onKeyDown={this.onKeyDown}
disabled={isDisabled}
autofocus={!!props.autofocus}
spellcheck={true}
/>
{charactersLeft < 100 && <span className="comment-form__counter">{charactersLeft}</span>}
</div>
{(isErrorShown || !!errorMessage) &&
(errorMessage || 'Something went wrong. Please try again a bit later.').split('\n').map(e => (
<p className="comment-form__error" role="alert" key={e}>
{e}
</p>
))}
<div className="comment-form__actions">
{!props.simpleView && (
<Button
kind="secondary"
theme={props.theme}
size="large"
mix="comment-form__button"
disabled={isDisabled}
onClick={this.getPreview}
>
Preview
</Button>
)}
<Button kind="primary" size="large" mix="comment-form__button" type="submit" disabled={isDisabled}>
{label}
</Button>
{!props.simpleView && props.mode === 'main' && (
<div className="comment-form__rss">
<div className="comment-form__markdown">
Styling with{' '}
<a className="comment-form__markdown-link" target="_blank" href="markdown-help.html">
Markdown
</a>{' '}
is supported
</div>
Subscribe to&nbsp;the{' '}
<a className="comment-form__rss-link" href={RSS_THREAD_URL} target="_blank">
Thread
</a>
{', '}
<a className="comment-form__rss-link" href={RSS_SITE_URL} target="_blank">
Site
</a>{' '}
or&nbsp;
<a className="comment-form__rss-link" href={RSS_REPLIES_URL + props.userId} target="_blank">
Replies
</a>{' '}
by&nbsp;RSS
</div>
)}
</div>
{// TODO: it can be more elegant;
// for example it can render full comment component here (or above textarea on mobile)
!!preview && (
<div className="comment-form__preview-wrapper">
<div
className={b('comment-form__preview', { mix: b('raw-content', {}, { theme: props.theme }) })}
dangerouslySetInnerHTML={{ __html: preview }}
/>
</div>
)}
</form>
);
}
}
@@ -0,0 +1,29 @@
export { CommentForm } from './comment-form';
import '@app/components/raw-content';
import './comment-form.scss';
import './__actions/comment-form__actions.scss';
import './__button/comment-form__button.scss';
import './__button/_type/_preview/comment-form__button_type_preview.scss';
import './__button/_type/_send/comment-form__button_type_send.scss';
import './__control-panel/comment-form__control-panel.scss';
import './__counter/comment-form__counter.scss';
import './__error/comment-form__error.scss';
import './__field/comment-form__field.scss';
import './__field-wrapper/comment-form__field-wrapper.scss';
import './__preview/comment-form__preview.scss';
import './__preview-wrapper/comment-form__preview-wrapper.scss';
import './__rss/comment-form__rss.scss';
import './__rss-link/comment-form__rss-link.scss';
import './__markdown/comment-form__markdown.scss';
import './__markdown-link/comment-form__markdown-link.scss';
import './__markdown-toolbar/comment-form__markdown-toolbar.scss';
import './_theme/_dark/comment-form_theme_dark.scss';
import './_theme/_light/comment-form_theme_light.scss';
import './_simple/comment-form_simple.scss';
@@ -3,7 +3,14 @@ import { createElement } from 'preact';
export default function BoldIcon() {
return (
<svg className="input__toolbar-icon" viewBox="0 0 10 16" version="1.1" width="10" height="16" aria-hidden="true">
<svg
className="comment-form__toolbar-icon"
viewBox="0 0 10 16"
version="1.1"
width="10"
height="16"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M1 2h3.83c2.48 0 4.3.75 4.3 2.95 0 1.14-.63 2.23-1.67 2.61v.06c1.33.3 2.3 1.23 2.3 2.86 0 2.39-1.97 3.52-4.61 3.52H1V2zm3.66 4.95c1.67 0 2.38-.66 2.38-1.69 0-1.17-.78-1.61-2.34-1.61H3.13v3.3h1.53zm.27 5.39c1.77 0 2.75-.64 2.75-1.98 0-1.27-.95-1.81-2.75-1.81h-1.8v3.8h1.8v-.01z"
@@ -3,7 +3,14 @@ import { createElement } from 'preact';
export default function CodeIcon() {
return (
<svg className="input__toolbar-icon" viewBox="0 0 14 16" version="1.1" width="14" height="16" aria-hidden="true">
<svg
className="comment-form__toolbar-icon"
viewBox="0 0 14 16"
version="1.1"
width="14"
height="16"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M9.5 3L8 4.5 11.5 8 8 11.5 9.5 13 14 8 9.5 3zm-5 0L0 8l4.5 5L6 11.5 2.5 8 6 4.5 4.5 3z"
@@ -3,7 +3,14 @@ import { createElement } from 'preact';
export default function HeaderIcon() {
return (
<svg className="input__toolbar-icon" viewBox="0 0 18 16" version="1.1" width="18" height="16" aria-hidden="true">
<svg
className="comment-form__toolbar-icon"
viewBox="0 0 18 16"
version="1.1"
width="18"
height="16"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M13.62 9.08L12.1 3.66h-.06l-1.5 5.42h3.08zM5.7 10.13S4.68 6.52 4.53 6.02h-.08l-1.13 4.11H5.7zM17.31 14h-2.25l-.95-3.25h-4.07L9.09 14H6.84l-.69-2.33H2.87L2.17 14H0l3.3-9.59h2.5l2.17 6.34L10.86 2h2.52l3.94 12h-.01z"
@@ -3,7 +3,7 @@ import { createElement } from 'preact';
export default function ImageIcon() {
return (
<svg className="input__toolbar-icon" width="11.25" height="15" viewBox="0 0 384 512" aria-hidden="true">
<svg className="comment-form__toolbar-icon" width="11.25" height="15" viewBox="0 0 384 512" aria-hidden="true">
<path
fill-rule="evenodd"
d="M369.9 97.9L286 14C277 5 264.8-.1 252.1-.1H48C21.5 0 0 21.5 0 48v416c0 26.5 21.5 48 48 48h288c26.5 0 48-21.5 48-48V131.9c0-12.7-5.1-25-14.1-34zM332.1 128H256V51.9l76.1 76.1zM48 464V48h160v104c0 13.3 10.7 24 24 24h104v288H48zm32-48h224V288l-23.5-23.5c-4.7-4.7-12.3-4.7-17 0L176 352l-39.5-39.5c-4.7-4.7-12.3-4.7-17 0L80 352v64zm48-240c-26.5 0-48 21.5-48 48s21.5 48 48 48 48-21.5 48-48-21.5-48-48-48z"
@@ -3,7 +3,14 @@ import { createElement } from 'preact';
export default function ItalicIcon() {
return (
<svg className="input__toolbar-icon" viewBox="0 0 6 16" version="1.1" width="6" height="16" aria-hidden="true">
<svg
className="comment-form__toolbar-icon"
viewBox="0 0 6 16"
version="1.1"
width="6"
height="16"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M2.81 5h1.98L3 14H1l1.81-9zm.36-2.7c0-.7.58-1.3 1.33-1.3.56 0 1.13.38 1.13 1.03 0 .75-.59 1.3-1.33 1.3-.58 0-1.13-.38-1.13-1.03z"
@@ -3,7 +3,14 @@ import { createElement } from 'preact';
export default function LinkIcon() {
return (
<svg className="input__toolbar-icon" viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true">
<svg
className="comment-form__toolbar-icon"
viewBox="0 0 16 16"
version="1.1"
width="16"
height="16"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"
@@ -3,7 +3,14 @@ import { createElement } from 'preact';
export default function OrderedListIcon() {
return (
<svg className="input__toolbar-icon" viewBox="0 0 12 16" version="1.1" width="12" height="16" aria-hidden="true">
<svg
className="comment-form__toolbar-icon"
viewBox="0 0 12 16"
version="1.1"
width="12"
height="16"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M12.01 13c0 .59 0 1-.59 1H4.6c-.59 0-.59-.41-.59-1 0-.59 0-1 .59-1h6.81c.59 0 .59.41.59 1h.01zM4.6 4h6.81C12 4 12 3.59 12 3c0-.59 0-1-.59-1H4.6c-.59 0-.59.41-.59 1 0 .59 0 1 .59 1zm6.81 3H4.6c-.59 0-.59.41-.59 1 0 .59 0 1 .59 1h6.81C12 9 12 8.59 12 8c0-.59 0-1-.59-1zm-9.4-6h-.72c-.3.19-.58.25-1.03.34V2h.75v2.14H.17V5h2.84v-.86h-1V1zm.392 8.12c-.129 0-.592.04-.802.07.53-.56 1.14-1.25 1.14-1.89C2.72 6.52 2.18 6 1.38 6c-.59 0-.97.2-1.38.64l.58.58c.19-.19.38-.38.64-.38.28 0 .48.16.48.52 0 .53-.77 1.2-1.7 2.06V10h3v-.88h-.598zm-.222 3.79v-.03c.44-.19.64-.47.64-.86 0-.7-.56-1.11-1.44-1.11-.48 0-.89.19-1.28.52l.55.64c.25-.2.44-.31.69-.31.27 0 .42.13.42.36 0 .27-.2.44-.86.44v.75c.83 0 .98.17.98.47 0 .25-.23.38-.58.38-.28 0-.56-.14-.81-.38l-.48.66c.3.36.77.56 1.41.56.83 0 1.53-.41 1.53-1.16 0-.5-.31-.81-.77-.94v.01z"
@@ -3,7 +3,14 @@ import { createElement } from 'preact';
export default function QuoteIcon() {
return (
<svg className="input__toolbar-icon" viewBox="0 0 14 16" version="1.1" width="14" height="16" aria-hidden="true">
<svg
className="comment-form__toolbar-icon"
viewBox="0 0 14 16"
version="1.1"
width="14"
height="16"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M6.16 3.5C3.73 5.06 2.55 6.67 2.55 9.36c.16-.05.3-.05.44-.05 1.27 0 2.5.86 2.5 2.41 0 1.61-1.03 2.61-2.5 2.61-1.9 0-2.99-1.52-2.99-4.25 0-3.8 1.75-6.53 5.02-8.42L6.16 3.5zm7 0c-2.43 1.56-3.61 3.17-3.61 5.86.16-.05.3-.05.44-.05 1.27 0 2.5.86 2.5 2.41 0 1.61-1.03 2.61-2.5 2.61-1.89 0-2.98-1.52-2.98-4.25 0-3.8 1.75-6.53 5.02-8.42l1.14 1.84h-.01z"
@@ -3,7 +3,14 @@ import { createElement } from 'preact';
export default function UnorderedListIcon() {
return (
<svg className="input__toolbar-icon" viewBox="0 0 12 16" version="1.1" width="12" height="16" aria-hidden="true">
<svg
className="comment-form__toolbar-icon"
viewBox="0 0 12 16"
version="1.1"
width="12"
height="16"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M2 13c0 .59 0 1-.59 1H.59C0 14 0 13.59 0 13c0-.59 0-1 .59-1h.81c.59 0 .59.41.59 1H2zm2.59-9h6.81c.59 0 .59-.41.59-1 0-.59 0-1-.59-1H4.59C4 2 4 2.41 4 3c0 .59 0 1 .59 1zM1.41 7H.59C0 7 0 7.41 0 8c0 .59 0 1 .59 1h.81c.59 0 .59-.41.59-1 0-.59 0-1-.59-1h.01zm0-5H.59C0 2 0 2.41 0 3c0 .59 0 1 .59 1h.81c.59 0 .59-.41.59-1 0-.59 0-1-.59-1h.01zm10 5H4.59C4 7 4 7.41 4 8c0 .59 0 1 .59 1h6.81c.59 0 .59-.41.59-1 0-.59 0-1-.59-1h.01zm0 5H4.59C4 12 4 12.41 4 13c0 .59 0 1 .59 1h6.81c.59 0 .59-.41.59-1 0-.59 0-1-.59-1h.01z"
@@ -50,40 +50,48 @@ export default class MarkdownToolbar extends Component<Props> {
}
render(props: Props) {
return (
<markdown-toolbar className="input__toolbar" for={props.textareaId}>
<div className="input__toolbar-group">
<md-header className="input__toolbar-item" title={headerLabel} aria-label={headerLabel}>
<markdown-toolbar className="comment-form__toolbar" for={props.textareaId}>
<div className="comment-form__toolbar-group">
<md-header className="comment-form__toolbar-item" title={headerLabel} aria-label={headerLabel}>
<HeaderIcon />
</md-header>
<md-bold className="input__toolbar-item" title={boldLabel} aria-label={boldLabel}>
<md-bold className="comment-form__toolbar-item" title={boldLabel} aria-label={boldLabel}>
<BoldIcon />
</md-bold>
<md-italic className="input__toolbar-item" title={italicLabel} aria-label={italicLabel}>
<md-italic className="comment-form__toolbar-item" title={italicLabel} aria-label={italicLabel}>
<ItalicIcon />
</md-italic>
</div>
<div className="input__toolbar-group">
<md-quote className="input__toolbar-item" title={quoteLabel} aria-label={quoteLabel}>
<div className="comment-form__toolbar-group">
<md-quote className="comment-form__toolbar-item" title={quoteLabel} aria-label={quoteLabel}>
<QuoteIcon />
</md-quote>
<md-code className="input__toolbar-item" title={codeLabel} aria-label={codeLabel}>
<md-code className="comment-form__toolbar-item" title={codeLabel} aria-label={codeLabel}>
<CodeIcon />
</md-code>
<md-link className="input__toolbar-item" title={linkLabel} aria-label={linkLabel}>
<md-link className="comment-form__toolbar-item" title={linkLabel} aria-label={linkLabel}>
<LinkIcon />
</md-link>
{this.props.allowUpload ? (
<label className="input__toolbar-item" title={attachImageLabel} aria-label={attachImageLabel}>
<input multiple className="input__toolbar-file-input" type="file" onChange={this.uploadImages} />
<label className="comment-form__toolbar-item" title={attachImageLabel} aria-label={attachImageLabel}>
<input multiple className="comment-form__toolbar-file-input" type="file" onChange={this.uploadImages} />
<ImageIcon />
</label>
) : null}
</div>
<div className="input__toolbar-group">
<md-unordered-list className="input__toolbar-item" title={unorderedListLabel} aria-label={unorderedListLabel}>
<div className="comment-form__toolbar-group">
<md-unordered-list
className="comment-form__toolbar-item"
title={unorderedListLabel}
aria-label={unorderedListLabel}
>
<UnorderedListIcon />
</md-unordered-list>
<md-ordered-list className="input__toolbar-item" title={orderedListLabel} aria-label={orderedListLabel}>
<md-ordered-list
className="comment-form__toolbar-item"
title={orderedListLabel}
aria-label={orderedListLabel}
>
<OrderedListIcon />
</md-ordered-list>
</div>
@@ -11,6 +11,7 @@
text-align: center;
border: 1px solid;
border-radius: 2px;
cursor: pointer;
&:hover {
border-color: #0aa;
@@ -1,5 +0,0 @@
.comment__action_type_delete {
&:hover {
color: #31c7c5;
}
}
@@ -1,5 +0,0 @@
.comment__action_type_edit {
&:hover {
color: #31c7c5;
}
}
@@ -1,13 +1,7 @@
.comment__action {
font-size: 14px;
vertical-align: middle;
cursor: pointer;
user-select: none;
color: #259c9a;
&:hover {
color: #31c7c5;
}
font-weight: normal;
+ .comment__action {
margin-left: 8px;
@@ -1,13 +1,8 @@
.comment__control {
margin-right: 8px;
cursor: pointer;
color: #8cd4d4;
&:last-child {
margin-right: 0;
}
&:hover {
color: #0aa;
}
}
@@ -9,10 +9,7 @@
}
}
.comment__action_type_delete {
color: #6a6a6a;
}
.comment__action_type_delete,
.comment__action_type_edit {
color: #6a6a6a;
}
@@ -9,10 +9,7 @@
}
}
.comment__action_type_delete {
color: #a6a6a6;
}
.comment__action_type_delete,
.comment__action_type_edit {
color: #a6a6a6;
}
@@ -182,7 +182,8 @@ describe('<Comment />', () => {
<Comment {...({ ...DefaultProps, user: { ...DefaultProps.user, admin: true } } as Props)} />
);
const controls = element.find('.comment__controls > span');
const controls = element.find('.comment__controls').children();
expect(controls.length).toBe(5);
expect(controls.at(0).text()).toEqual('Copy');
expect(controls.at(1).text()).toEqual('Pin');
@@ -196,7 +197,7 @@ describe('<Comment />', () => {
<Comment {...({ ...DefaultProps, user: { ...DefaultProps.user, admin: false } } as Props)} />
);
const controls = element.find('.comment__controls > span');
const controls = element.find('.comment__controls').children();
expect(controls.length).toBe(1);
expect(controls.at(0).text()).toEqual('Hide');
});
+25 -22
View File
@@ -15,9 +15,10 @@ import { Theme, BlockTTL, Comment as CommentType, PostInfo, User, CommentMode }
import { extractErrorMessageFromResponse, FetcherError } from '@app/utils/errorUtils';
import { isUserAnonymous } from '@app/utils/isUserAnonymous';
import { Input } from '@app/components/input';
import { CommentForm } from '@app/components/comment-form';
import { AvatarIcon } from '@app/components/avatar-icon';
import Countdown from '../countdown';
import { Button } from '@app/components/button';
import Countdown from '@app/components/countdown';
import { boundActions } from './connected-comment';
import { getPreview, uploadImage } from '@app/common/api';
import postMessage from '@app/utils/postMessage';
@@ -388,33 +389,33 @@ export class Comment extends Component<Props, State> {
this.state.isCopied ? (
<span className="comment__control comment__control_view_inactive">Copied!</span>
) : (
<span {...getHandleClickProps(this.copyComment)} className="comment__control">
<Button kind="link" {...getHandleClickProps(this.copyComment)} mix="comment__control">
Copy
</span>
</Button>
)
);
controls.push(
<span {...getHandleClickProps(this.togglePin)} className="comment__control">
<Button kind="link" {...getHandleClickProps(this.togglePin)} mix="comment__control">
{this.props.data.pin ? 'Unpin' : 'Pin'}
</span>
</Button>
);
}
if (!isCurrentUser) {
controls.push(
<span {...getHandleClickProps(this.hideUser)} className="comment__control">
<Button kind="link" {...getHandleClickProps(this.hideUser)} mix="comment__control">
Hide
</span>
</Button>
);
}
if (isAdmin) {
if (this.props.isUserBanned) {
controls.push(
<span {...getHandleClickProps(this.onUnblockUserClick)} className="comment__control">
<Button kind="link" {...getHandleClickProps(this.onUnblockUserClick)} mix="comment__control">
Unblock
</span>
</Button>
);
}
@@ -437,9 +438,9 @@ export class Comment extends Component<Props, State> {
if (!this.props.data.delete) {
controls.push(
<span {...getHandleClickProps(this.deleteComment)} className="comment__control">
<Button kind="link" {...getHandleClickProps(this.deleteComment)} mix="comment__control">
Delete
</span>
</Button>
);
}
}
@@ -678,9 +679,9 @@ export class Comment extends Component<Props, State> {
{(!props.collapsed || props.view === 'pinned') && (
<div className="comment__actions">
{!props.data.delete && !props.isCommentsDisabled && !props.disabled && !isGuest && props.view === 'main' && (
<span {...getHandleClickProps(this.toggleReplying)} className="comment__action">
<Button kind="link" {...getHandleClickProps(this.toggleReplying)} mix="comment__action">
{isReplying ? 'Cancel' : 'Reply'}
</span>
</Button>
)}
{!props.data.delete &&
!props.disabled &&
@@ -688,19 +689,21 @@ export class Comment extends Component<Props, State> {
isCurrentUser &&
(editable || isEditing) &&
props.view === 'main' && [
<span
<Button
kind="link"
{...getHandleClickProps(this.toggleEditing)}
className="comment__action comment__action_type_edit"
mix={['comment__action', 'comment__action_type_edit']}
>
{isEditing ? 'Cancel' : 'Edit'}
</span>,
</Button>,
!isAdmin && (
<span
<Button
kind="link"
{...getHandleClickProps(this.deleteComment)}
className="comment__action comment__action_type_delete"
mix={['comment__action', 'comment__action_type_delete']}
>
Delete
</span>
</Button>
),
state.editDeadline && (
<Countdown
@@ -721,7 +724,7 @@ export class Comment extends Component<Props, State> {
</div>
{isReplying && props.view === 'main' && (
<Input
<CommentForm
theme={props.theme}
value=""
mode="reply"
@@ -736,7 +739,7 @@ export class Comment extends Component<Props, State> {
)}
{isEditing && props.view === 'main' && (
<Input
<CommentForm
theme={props.theme}
value={o.orig}
mode="edit"
@@ -4,8 +4,6 @@ import './comment.scss';
import './__action/comment__action.scss';
import './__action/_type/_collapse/comment__action_type_collapse.scss';
import './__action/_type/_edit/comment__action_type_edit.scss';
import './__action/_type/_delete/comment__action_type_delete.scss';
import './__edit-timer/comment__edit-timer.scss';
@@ -1,10 +1,10 @@
.dropdown__item {
& > button,
& > a,
& > button {
display: block;
width: 100%;
& > .dropdown {
display: inline-block;
text-align: left;
padding: 5px 15px;
padding: 5px;
}
&_separator {
@@ -1,10 +1,4 @@
.dropdown__title {
border: none;
background: none;
font-weight: bold;
padding: 0;
margin: 0;
&::after {
content: '\25BE';
margin-left: 2px;
@@ -1,8 +1,4 @@
.dropdown_theme_dark {
.dropdown__title {
color: #ddd;
}
.dropdown__content {
background-color: #22201c;
}
@@ -1,8 +1,4 @@
.dropdown_theme_light {
.dropdown__title {
color: #000;
}
.dropdown__content {
background-color: #fff;
}
+12 -12
View File
@@ -2,9 +2,9 @@
import { createElement, Component, createRef } from 'preact';
import b from 'bem-react-helper';
import { Button } from '@app/components/button';
import { Theme } from '@app/common/types';
import { sleep } from '@app/utils/sleep';
import { Button } from '@app/components/button';
interface Props {
title: string;
@@ -25,6 +25,9 @@ interface State {
export default class Dropdown extends Component<Props, State> {
rootNode = createRef<HTMLDivElement>();
storedDocumentHeight: string | null = null;
storedDocumentHeightSet: boolean = false;
checkInterval: number | undefined = undefined;
constructor(props: Props) {
super(props);
@@ -40,7 +43,7 @@ export default class Dropdown extends Component<Props, State> {
this.__onClose = this.__onClose.bind(this);
}
onTitleClick() {
onTitleClick = () => {
const isActive = !this.state.isActive;
const contentTranslateX = isActive ? this.state.contentTranslateX : 0;
this.setState(
@@ -63,11 +66,7 @@ export default class Dropdown extends Component<Props, State> {
}
}
);
}
storedDocumentHeight: string | null = null;
storedDocumentHeightSet: boolean = false;
checkInterval: number | undefined = undefined;
};
__onOpen() {
const isChildOfDropDown = (() => {
@@ -90,6 +89,7 @@ export default class Dropdown extends Component<Props, State> {
if (!this.rootNode.current || !this.state.isActive) return;
const windowHeight = window.innerHeight;
const dcBottom = (() => {
// TODO: use ref
const dc = Array.from(this.rootNode.current.children).find(c => c.classList.contains('dropdown__content'));
if (!dc) return 0;
const rect = dc.getBoundingClientRect();
@@ -112,6 +112,7 @@ export default class Dropdown extends Component<Props, State> {
async __adjustDropDownContent() {
if (!this.rootNode.current) return;
// TODO: use ref
const dc = this.rootNode.current.querySelector<HTMLDivElement>('.dropdown__content');
if (!dc) return;
await sleep(10);
@@ -185,11 +186,10 @@ export default class Dropdown extends Component<Props, State> {
<Button
aria-haspopup="listbox"
aria-expanded={isActive && 'true'}
mix="dropdown__title"
type="button"
onClick={() => this.onTitleClick()}
theme="light"
className={titleClass}
onClick={this.onTitleClick}
theme={theme}
mix={['dropdown__title', titleClass]}
kind="link"
>
{title}
</Button>
@@ -1,14 +0,0 @@
.input__button {
box-sizing: border-box;
margin: 0 8px 0 0;
padding: 8px 12px;
font-size: 16px;
border: 0;
border-radius: 2px;
cursor: pointer;
&:disabled {
opacity: 0.5;
pointer-events: none;
}
}
@@ -1,3 +0,0 @@
.input__field-wrapper {
position: relative;
}
@@ -1,3 +0,0 @@
.input__markdown {
margin-bottom: 5px;
}
@@ -1,3 +0,0 @@
.input__preview-wrapper {
overflow: hidden;
}
+2
View File
@@ -1 +1,3 @@
export { Input } from './input';
import './input.scss';
+11 -6
View File
@@ -1,8 +1,13 @@
.input {
position: relative;
display: block;
font-size: 0;
border-style: solid;
border-width: 6px 12px 12px 12px;
border-radius: 2px;
font-size: 16px;
font-family: inherit;
padding: 4px 8px;
border: 1px solid #c4c4c4;
margin: 0;
&:focus {
box-shadow: 0 0 0 2px rgba(37, 156, 154, 0.4);
border-color: #259c9a;
outline: none;
}
}
@@ -1,15 +0,0 @@
/** @jsx createElement */
import { createElement } from 'preact';
import { shallow } from 'enzyme';
import { Input, Props } from './input';
describe('<Input />', () => {
it('shoud render without control panel, preview button, and rss links in "simple view" mode', () => {
const element = shallow(<Input {...({ simpleView: true } as Props)} />);
expect(element.exists('.input__control-panel')).toEqual(false);
expect(element.exists('.input__button_type_preview')).toEqual(false);
expect(element.exists('.input__rss')).toEqual(false);
});
});
+16 -447
View File
@@ -1,456 +1,25 @@
/** @jsx createElement */
import { createElement, JSX } from 'preact';
import { forwardRef } from 'preact/compat';
import b, { Mods, Mix } from 'bem-react-helper';
import { Theme } from '@app/common/types';
/* styles imports */
import '@app/components/raw-content';
import './styles';
import { createElement, Component, createRef } from 'preact';
import b, { Mix } from 'bem-react-helper';
import { User, Theme, Image, ApiError } from '@app/common/types';
import { BASE_URL, API_BASE } from '@app/common/constants';
import { StaticStore } from '@app/common/static_store';
import { siteId, url, pageTitle } from '@app/common/settings';
import { extractErrorMessageFromResponse } from '@app/utils/errorUtils';
import MarkdownToolbar from './markdown-toolbar';
import TextareaAutosize from './textarea-autosize';
import { sleep } from '@app/utils/sleep';
import { replaceSelection } from '@app/utils/replaceSelection';
const RSS_THREAD_URL = `${BASE_URL}${API_BASE}/rss/post?site=${siteId}&url=${url}`;
const RSS_SITE_URL = `${BASE_URL}${API_BASE}/rss/site?site=${siteId}`;
const RSS_REPLIES_URL = `${BASE_URL}${API_BASE}/rss/reply?site=${siteId}&user=`;
let textareaId = 0;
export interface Props {
/** user id for rss link generation */
userId?: User['id'];
errorMessage?: string;
value?: string;
interface Props extends JSX.HTMLAttributes {
kind?: 'primary' | 'secondary';
theme?: Theme;
mods?: Mods;
mix?: Mix;
mode?: 'main' | 'edit' | 'reply';
theme: Theme;
simpleView: boolean | undefined;
autofocus?: boolean;
onSubmit(text: string, pageTitle: string): Promise<void>;
getPreview(text: string): Promise<string>;
/** action on cancel. optional as root input has no cancel option */
onCancel?: () => void;
uploadImage?: (image: File) => Promise<Image>;
type?: string;
}
interface State {
preview: string | null;
isErrorShown: boolean;
/** error message, if contains newlines, it will be splitted to multiple errors */
errorMessage: string | null;
/** prevents error hiding on input event */
errorLock: boolean;
isDisabled: boolean;
maxLength: number;
/** main input value */
text: string;
/** override main button text */
buttonText: null | string;
}
const Labels = {
main: 'Send',
edit: 'Save',
reply: 'Reply',
};
const ImageMimeRegex = /image\//i;
export class Input extends Component<Props, State> {
/** reference to textarea element */
textAreaRef = createRef<TextareaAutosize>();
textareaId: string;
constructor(props: Props) {
super(props);
textareaId = textareaId + 1;
this.textareaId = `textarea_${textareaId}`;
this.state = {
preview: null,
isErrorShown: false,
errorMessage: null,
errorLock: false,
isDisabled: false,
maxLength: StaticStore.config.max_comment_size,
text: props.value || '',
buttonText: null,
};
this.send = this.send.bind(this);
this.getPreview = this.getPreview.bind(this);
this.onInput = this.onInput.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.onDragOver = this.onDragOver.bind(this);
this.onDrop = this.onDrop.bind(this);
this.appendError = this.appendError.bind(this);
this.uploadImage = this.uploadImage.bind(this);
this.uploadImages = this.uploadImages.bind(this);
this.onPaste = this.onPaste.bind(this);
}
componentWillReceiveProps(nextProps: Props) {
if (nextProps.value !== this.props.value) {
this.setState({ text: nextProps.value || '' });
this.props.autofocus && this.textAreaRef.current && this.textAreaRef.current.focus();
}
}
shouldComponentUpdate(nextProps: Props, nextState: State) {
return (
nextProps.mode !== this.props.mode ||
nextProps.theme !== this.props.theme ||
nextProps.userId !== this.props.userId ||
nextProps.value !== this.props.value ||
nextProps.errorMessage !== this.props.errorMessage ||
nextState !== this.state
);
}
onKeyDown(e: KeyboardEvent) {
// send on cmd+enter / ctrl+enter
if (e.keyCode === 13 && (e.metaKey || e.ctrlKey)) {
this.send(e);
}
}
onInput(e: Event) {
if (this.state.errorLock) {
this.setState({
preview: null,
text: (e.target as HTMLInputElement).value,
});
return;
}
this.setState({
isErrorShown: false,
errorMessage: null,
preview: null,
text: (e.target as HTMLInputElement).value,
});
}
async onPaste(e: ClipboardEvent) {
if (!(e.clipboardData && e.clipboardData.files.length > 0)) {
return;
}
e.preventDefault();
const files = Array.from(e.clipboardData.files);
await this.uploadImages(files);
}
send(e: Event) {
const text = this.state.text;
const props = this.props;
if (e) e.preventDefault();
if (!text || !text.trim()) return;
if (text === this.props.value) {
this.props.onCancel && this.props.onCancel();
this.setState({ preview: null, text: '' });
}
this.setState({ isDisabled: true, isErrorShown: false });
props
.onSubmit(text, pageTitle || document.title)
.then(() => {
this.setState({ preview: null, text: '' });
})
.catch(e => {
console.error(e); // eslint-disable-line no-console
const errorMessage = extractErrorMessageFromResponse(e);
this.setState({ isErrorShown: true, errorMessage });
})
.finally(() => this.setState({ isDisabled: false }));
}
getPreview() {
const text = this.state.text;
if (!text || !text.trim()) return;
this.setState({ isErrorShown: false, errorMessage: null });
this.props
.getPreview(text)
.then(preview => this.setState({ preview }))
.catch(() => {
this.setState({ isErrorShown: true, errorMessage: null });
});
}
/** appends error to input's error block */
appendError(...errors: string[]) {
if (!this.state.errorMessage) {
this.setState({
errorMessage: errors.join('\n'),
isErrorShown: true,
});
return;
}
this.setState({
errorMessage: this.state.errorMessage + '\n' + errors.join('\n'),
isErrorShown: true,
});
}
onDragOver(e: DragEvent) {
if (!this.props.uploadImage) return;
if (StaticStore.config.max_image_size === 0) return;
if (!this.textAreaRef) return;
if (!e.dataTransfer) return;
const items = Array.from(e.dataTransfer.items);
if (Array.from(items).filter(i => i.kind === 'file' && ImageMimeRegex.test(i.type)).length === 0) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}
onDrop(e: DragEvent) {
if (!this.props.uploadImage) return;
if (StaticStore.config.max_image_size === 0) return;
if (!e.dataTransfer) return;
const data = Array.from(e.dataTransfer.files).filter(f => ImageMimeRegex.test(f.type));
if (data.length === 0) return;
e.preventDefault();
this.uploadImages(data);
}
/** wrapper with error handling for props.uploadImage */
uploadImage(file: File): Promise<Image | Error> {
return this.props.uploadImage!(file).catch(
(e: ApiError | string) =>
new Error(
typeof e === 'string'
? `${file.name} upload failed with "${e}"`
: `${file.name} upload failed with "${e.error}"`
)
);
}
/** performs upload process */
async uploadImages(files: File[]) {
if (!this.props.uploadImage) return;
if (!this.textAreaRef.current) return;
/** Human readable image size limit, i.e 5MB */
const maxImageSizeString = (StaticStore.config.max_image_size / 1024 / 1024).toFixed(2) + 'MB';
/** upload delay to avoid server rate limiter */
const uploadDelay = 5000;
const isSelectionSupported = this.textAreaRef.current.isSelectionSupported();
this.setState({
errorLock: true,
errorMessage: null,
isErrorShown: false,
isDisabled: true,
buttonText: 'Uploading...',
});
// fallback for ie < 9
if (!isSelectionSupported) {
for (let i = 0; i < files.length; i++) {
const file = files[i];
const isFirst = i === 0;
const placeholderStart = this.state.text.length === 0 ? '' : '\n';
if (file.size > StaticStore.config.max_image_size) {
this.appendError(`${file.name} exceeds size limit of ${maxImageSizeString}`);
continue;
}
!isFirst && (await sleep(uploadDelay));
const result = await this.uploadImage(file);
if (result instanceof Error) {
this.appendError(result.message);
continue;
}
const markdownString = `${placeholderStart}![${result.name}](${result.url})`;
this.setState({
text: this.state.text + markdownString,
});
}
this.setState({ errorLock: false, isDisabled: false, buttonText: null });
return;
}
for (let i = 0; i < files.length; i++) {
const file = files[i];
const isFirst = i === 0;
const placeholderStart = this.state.text.length === 0 ? '' : '\n';
const uploadPlaceholder = `${placeholderStart}![uploading ${file.name}...]()`;
const uploadPlaceholderLength = uploadPlaceholder.length;
const selection = this.textAreaRef.current.getSelection();
/** saved selection in case of error */
const originalText = this.state.text;
const restoreSelection = async () => {
this.setState({
text: originalText,
});
/** sleeping awhile so textarea catch state change and its selection */
await sleep(100);
this.textAreaRef.current!.setSelection(selection);
};
if (file.size > StaticStore.config.max_image_size) {
this.appendError(`${file.name} exceeds size limit of ${maxImageSizeString}`);
continue;
}
this.setState({
text: replaceSelection(this.state.text, selection, uploadPlaceholder),
});
!isFirst && (await sleep(uploadDelay));
const result = await this.uploadImage(file);
if (result instanceof Error) {
this.appendError(result.message);
await restoreSelection();
continue;
}
const markdownString = `${placeholderStart}![${result.name}](${result.url})`;
this.setState({
text: replaceSelection(this.state.text, [selection[0], selection[0] + uploadPlaceholderLength], markdownString),
});
/** sleeping awhile so textarea catch state change and its selection */
await sleep(100);
const selectionPointer = selection[0] + markdownString.length;
this.textAreaRef.current.setSelection([selectionPointer, selectionPointer]);
}
this.setState({ errorLock: false, isDisabled: false, buttonText: null });
}
render(props: Props, { isDisabled, isErrorShown, errorMessage, preview, maxLength, text, buttonText }: State) {
const charactersLeft = maxLength - text.length;
errorMessage = props.errorMessage || errorMessage;
const label = buttonText || Labels[props.mode || 'main'];
export const Input = forwardRef<HTMLInputElement, Props>(
({ children, theme, mods, mix, type = 'text', ...props }, ref) => {
const className = b('input', { mix }, { theme, ...mods });
return (
<form
className={b('input', {
mods: {
theme: props.theme || 'light',
type: props.mode || 'reply',
simple: props.simpleView,
},
mix: props.mix,
})}
onSubmit={this.send}
aria-label="New comment"
onDragOver={this.onDragOver}
onDrop={this.onDrop}
>
{!props.simpleView && (
<div className="input__control-panel">
<MarkdownToolbar
allowUpload={Boolean(this.props.uploadImage)}
uploadImages={this.uploadImages}
textareaId={this.textareaId}
/>
</div>
)}
<div className="input__field-wrapper">
<TextareaAutosize
id={this.textareaId}
onPaste={this.onPaste}
ref={this.textAreaRef}
className="input__field"
placeholder="Your comment here"
value={text}
maxLength={maxLength}
onInput={this.onInput}
onKeyDown={this.onKeyDown}
disabled={isDisabled}
autofocus={!!props.autofocus}
spellcheck={true}
/>
{charactersLeft < 100 && <span className="input__counter">{charactersLeft}</span>}
</div>
{(isErrorShown || !!errorMessage) &&
(errorMessage || 'Something went wrong. Please try again a bit later.').split('\n').map(e => (
<p className="input__error" role="alert" key={e}>
{e}
</p>
))}
<div className="input__actions">
{!props.simpleView && (
<button
className={b('input__button', {}, { type: 'preview' })}
type="button"
disabled={isDisabled}
onClick={this.getPreview}
>
Preview
</button>
)}
<button className={b('input__button', {}, { type: 'send' })} type="submit" disabled={isDisabled}>
{label}
</button>
{!props.simpleView && props.mode === 'main' && (
<div className="input__rss">
<div className="input__markdown">
Styling with{' '}
<a className="input__markdown-link" target="_blank" href="markdown-help.html">
Markdown
</a>{' '}
is supported
</div>
Subscribe to&nbsp;the{' '}
<a className="input__rss-link" href={RSS_THREAD_URL} target="_blank">
Thread
</a>
{', '}
<a className="input__rss-link" href={RSS_SITE_URL} target="_blank">
Site
</a>{' '}
or&nbsp;
<a className="input__rss-link" href={RSS_REPLIES_URL + props.userId} target="_blank">
Replies
</a>{' '}
by&nbsp;RSS
</div>
)}
</div>
{// TODO: it can be more elegant;
// for example it can render full comment component here (or above textarea on mobile)
!!preview && (
<div className="input__preview-wrapper">
<div
className={b('input__preview', { mix: b('raw-content', {}, { theme: props.theme }) })}
dangerouslySetInnerHTML={{ __html: preview }}
/>
</div>
)}
</form>
<input ref={ref} className={className} type={type} {...props}>
{children}
</input>
);
}
}
);
-26
View File
@@ -1,26 +0,0 @@
import '@app/components/raw-content';
import './input.scss';
import './_simple/input_simple.scss';
import './__actions/input__actions.scss';
import './__button/input__button.scss';
import './__button/_type/_preview/input__button_type_preview.scss';
import './__button/_type/_send/input__button_type_send.scss';
import './__control-panel/input__control-panel.scss';
import './__counter/input__counter.scss';
import './__error/input__error.scss';
import './__field/input__field.scss';
import './__field-wrapper/input__field-wrapper.scss';
import './__preview/input__preview.scss';
import './__preview-wrapper/input__preview-wrapper.scss';
import './__rss/input__rss.scss';
import './__rss-link/input__rss-link.scss';
import './__markdown/input__markdown.scss';
import './__markdown-link/input__markdown-link.scss';
import './__markdown-toolbar/input__markdown-toolbar.scss';
import './_theme/_dark/input_theme_dark.scss';
import './_theme/_light/input_theme_light.scss';
@@ -1,5 +1,5 @@
.root__pinned-comments {
margin-top: 24px;
margin-top: 20px;
padding: 8px 12px;
border-radius: 2px;
}
@@ -1,21 +1,5 @@
.root__show-more {
display: block;
width: 300px;
max-width: 100%;
height: 36px;
margin: 20px auto;
padding: 0;
background: #259c9a;
border-radius: 4px;
border: 0;
font-size: 14px;
line-height: 36px;
box-shadow: none;
color: #fff;
cursor: pointer;
&:hover,
&:focus {
background: #0aa;
}
}
+5 -4
View File
@@ -35,9 +35,10 @@ import { addComment, updateComment } from '@app/store/comments/actions';
import { AuthPanel } from '@app/components/auth-panel';
import Settings from '@app/components/settings';
import { ConnectedComment as Comment } from '@app/components/comment/connected-comment';
import { Input } from '@app/components/input';
import { CommentForm } from '@app/components/comment-form';
import Preloader from '@app/components/preloader';
import { Thread } from '@app/components/thread';
import { Button } from '@app/components/button';
import { uploadImage, getPreview } from '@app/common/api';
import { isUserAnonymous } from '@app/utils/isUserAnonymous';
import { bindActions } from '@app/utils/actionBinder';
@@ -241,7 +242,7 @@ export class Root extends Component<Props, State> {
{!this.props.isSettingsVisible && (
<div className="root__main">
{!isGuest && !isCommentsDisabled && (
<Input
<CommentForm
theme={props.theme}
mix="root__input"
mode="main"
@@ -284,9 +285,9 @@ export class Root extends Component<Props, State> {
))}
{commentsShown < this.props.topComments.length && IS_MOBILE && (
<button className="root__show-more" onClick={this.showMore}>
<Button kind="primary" size="middle" mix="root__show-more" onClick={this.showMore}>
Show more
</button>
</Button>
)}
</div>
)}
@@ -24,9 +24,9 @@ interface State {
* reapply block on unblocked user
*/
blockedUsers: BlockedUser[];
unblockedUsers: (User['id'])[];
unblockedUsers: User['id'][];
hiddenUsers: { [id: string]: User };
unhiddenUsers: (User['id'])[];
unhiddenUsers: User['id'][];
}
export default class Settings extends Component<Props, State> {
+1 -1
View File
@@ -22,7 +22,7 @@ const mapStateToProps = (state: StoreState, props: { id: CommentInterface['id']
interface OwnProps {
id: CommentInterface['id'];
childs?: (CommentInterface['id'])[];
childs?: CommentInterface['id'][];
level: number;
mix?: string;
+4 -7
View File
@@ -12,13 +12,10 @@ export const useActions = <Actions extends { [key: string]: Function }>(
return useMemo(
() =>
Object.entries(actions).reduce<BoundActionCreator<Actions>>(
(result, [key, fn]) => {
(result as any)[key] = (...args: any[]) => dispatch(fn(...args));
return result;
},
{} as any
),
Object.entries(actions).reduce<BoundActionCreator<Actions>>((result, [key, fn]) => {
(result as any)[key] = (...args: any[]) => dispatch(fn(...args));
return result;
}, {} as any),
[dispatch, ...Object.values(actions)]
) as any;
};
+13 -13
View File
@@ -16,12 +16,15 @@ import { getPinnedComments } from './utils';
import { cmpRef } from '@app/utils/cmpRef';
export const topComments = (
state: (Comment['id'])[] = [],
state: Comment['id'][] = [],
action: COMMENTS_SET_ACTION | COMMENTS_APPEND_ACTION
): (Comment['id'])[] => {
): Comment['id'][] => {
switch (action.type) {
case COMMENTS_SET: {
return cmpRef(state, action.comments.map(x => x.comment.id));
return cmpRef(
state,
action.comments.map(x => x.comment.id)
);
}
case COMMENTS_APPEND: {
if (action.comment.pid) return state;
@@ -32,10 +35,7 @@ export const topComments = (
}
};
const reduceChildIds = (
c: Record<Comment['id'], (Comment['id'])[]>,
x: Node
): Record<Comment['id'], (Comment['id'])[]> => {
const reduceChildIds = (c: Record<Comment['id'], Comment['id'][]>, x: Node): Record<Comment['id'], Comment['id'][]> => {
if (!x.replies) return c;
if (!c[x.comment.id]) {
c[x.comment.id] = [];
@@ -51,12 +51,12 @@ const reduceChildIds = (
};
export const childComments = (
state: Record<Comment['id'], (Comment['id'])[]> = {},
state: Record<Comment['id'], Comment['id'][]> = {},
action: COMMENTS_SET_ACTION | COMMENTS_APPEND_ACTION
): Record<Comment['id'], (Comment['id'])[]> => {
): Record<Comment['id'], Comment['id'][]> => {
switch (action.type) {
case COMMENTS_SET: {
return action.comments.reduce<Record<Comment['id'], (Comment['id'])[]>>(reduceChildIds, {});
return action.comments.reduce<Record<Comment['id'], Comment['id'][]>>(reduceChildIds, {});
}
case COMMENTS_APPEND: {
if (!action.comment.pid) return state;
@@ -134,9 +134,9 @@ export const activeComment = (
};
export const pinnedComments = (
state: (Comment['id'])[] = [],
state: Comment['id'][] = [],
action: COMMENTS_SET_ACTION | COMMENTS_EDIT_ACTION | COMMENTS_PATCH_ACTION
): (Comment['id'])[] => {
): Comment['id'][] => {
switch (action.type) {
case COMMENTS_SET: {
return getPinnedComments(action.comments).map(x => x.id);
@@ -157,7 +157,7 @@ export const pinnedComments = (
if (!action.patch.pin) {
return state.filter(x => action.ids.indexOf(x) === -1);
}
return [...state, ...action.ids].reduce<(Comment['id'])[]>((c, x) => {
return [...state, ...action.ids].reduce<Comment['id'][]>((c, x) => {
if (c.indexOf(x) === -1) {
c.push(x);
}
+1 -1
View File
@@ -26,7 +26,7 @@ export const COMMENTS_PATCH = 'COMMENTS/PATCH';
export interface COMMENTS_PATCH_ACTION {
type: typeof COMMENTS_PATCH;
ids: (Comment['id'])[];
ids: Comment['id'][];
patch: Partial<Comment>;
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { Comment } from '@app/common/types';
export const THREAD_RESTORE_COLLAPSE = 'THREAD/COLLAPSE_RESTORE';
export interface THREAD_RESTORE_COLLAPSE_ACTION {
type: typeof THREAD_RESTORE_COLLAPSE;
ids: (Comment['id'])[];
ids: Comment['id'][];
}
export const THREAD_SET_COLLAPSE = 'THREAD/COLLAPSE_SET';
+1 -1
View File
@@ -22,7 +22,7 @@ export const getCollapsedComments = (): string[] =>
/**
* @param info list of string of type "site-id_url_comment-id
*/
export const saveCollapsedComments = (siteId: string, url: string, info: (Comment['id'])[]): void => {
export const saveCollapsedComments = (siteId: string, url: string, info: Comment['id'][]): void => {
const data = info.map(i => `${siteId}_${url}_${i}`);
const notForThisPost = getFromLocalStorage().filter(entry => entry.indexOf(`${siteId}_${url}`) === -1);
const all = new Set([...notForThisPost, ...data]);
+111 -108
View File
@@ -1,118 +1,121 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>remark42 demo page</title>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>remark42 demo page</title>
<style>
body {
padding: 0;
margin: 0;
}
.container {
max-width: 800px;
padding: 40px;
}
.document_theme_dark {
background: #22201c;
color: #ddd;
}
.document_theme_dark a {
color: #5e5eff;
}
.widgets {
display: flex;
flex-wrap: nowrap;
}
.widget {
margin-top: 1rem;
}
.widget__comments-frame {
border: 1px dashed #aaa;
width: 35rem;
max-width: 100%;
}
.widgets__counter-widget {
margin-left: 1em;
}
@media screen and (max-width: 800px) {
.container {
padding: 1em;
}
.widgets {
flex-direction: column
}
.widgets__counter-widget {
margin-left: 0;
}
}
</style>
<script>
function toggleTheme() {
var body = document.querySelector('body');
if (body.classList.contains('document_theme_dark')) {
body.classList.add('document_theme_light');
body.classList.remove('document_theme_dark');
window.REMARK42.changeTheme('light');
} else {
body.classList.add('document_theme_dark');
body.classList.remove('document_theme_light');
window.REMARK42.changeTheme('dark');
<style>
body {
padding: 0;
margin: 0;
font-family: -system-ui, sans-serif;
}
}
</script>
</head>
<body>
<div class="container">
<h1>Demo page</h1>
<p>To install widgets on your website, follow the <a href="https://github.com/umputun/remark#setup-on-your-website">instructions</a>.</p>
<div class="widgets">
<div class="widget widgets__widget widgets__comments-widget">
<a class="widget__link" href="/web/last-comments.html">Last comments widget page</a><br/>
<iframe class="widget__frame widget__comments-frame" src="/web/last-comments.html" frameborder="0"></iframe>
</div>
<div class="widget widgets__widget widgets__counter-widget">
<a class="widget__link" href="/web/counter.html">Counter widget page</a><br/>
<div class="widget__frame widget__counter-frame">
Comments count: <span class="remark42__counter"></span>
.container {
max-width: 800px;
padding: 40px;
}
.document_theme_dark {
background: #22201c;
color: #ddd;
}
.document_theme_dark a {
color: #5e5eff;
}
.widgets {
display: flex;
flex-wrap: nowrap;
}
.widget {
margin-top: 1rem;
}
.widget__comments-frame {
border: 1px dashed #aaa;
width: 35rem;
max-width: 100%;
}
.widgets__counter-widget {
margin-left: 1em;
}
@media screen and (max-width: 800px) {
.container {
padding: 1em;
}
.widgets {
flex-direction: column;
}
.widgets__counter-widget {
margin-left: 0;
}
}
</style>
<script>
function toggleTheme() {
var body = document.querySelector('body');
if (body.classList.contains('document_theme_dark')) {
body.classList.add('document_theme_light');
body.classList.remove('document_theme_dark');
window.REMARK42.changeTheme('light');
} else {
body.classList.add('document_theme_dark');
body.classList.remove('document_theme_light');
window.REMARK42.changeTheme('dark');
}
}
</script>
</head>
<body>
<div class="container">
<h1>Demo page</h1>
<p>
To install widgets on your website, follow the
<a href="https://github.com/umputun/remark#setup-on-your-website">instructions</a>.
</p>
<div class="widgets">
<div class="widget widgets__widget widgets__comments-widget">
<a class="widget__link" href="/web/last-comments.html">Last comments widget page</a><br />
<iframe class="widget__frame widget__comments-frame" src="/web/last-comments.html" frameborder="0"></iframe>
</div>
<div class="widget widgets__widget widgets__counter-widget">
<a class="widget__link" href="/web/counter.html">Counter widget page</a><br />
<div class="widget__frame widget__counter-frame">Comments count: <span class="remark42__counter"></span></div>
</div>
</div>
<p><button onclick="toggleTheme()">Toggle theme</button></p>
<div id="remark42"></div>
</div>
<p><Button onclick="toggleTheme()">Toggle theme</Button></p>
<div id="remark42"></div>
</div>
<script>
var remark_config = {
site_id: 'remark',
host: window.location.origin,
url: 'https://remark42.com/demo/',
components: ['embed', 'counter']
};
<script>
var remark_config = {
site_id: 'remark',
host: window.location.origin,
url: 'https://remark42.com/demo/',
components: ['embed', 'counter']
};
(function(c) {
for(var i = 0; i < c.length; i++){
var d = document, s = d.createElement('script');
s.src = remark_config.host + '/web/' +c[i] +'.js';
(d.head || d.body).appendChild(s);
}
})(remark_config.components || ['embed']);
</script>
<noscript>
Please enable JavaScript to view the comments powered by Remark.
</noscript>
</body>
(function(c) {
for (var i = 0; i < c.length; i++) {
var d = document,
s = d.createElement('script');
s.src = remark_config.host + '/web/' + c[i] + '.js';
(d.head || d.body).appendChild(s);
}
})(remark_config.components || ['embed']);
</script>
<noscript>
Please enable JavaScript to view the comments powered by Remark.
</noscript>
</body>
</html>
+3 -3
View File
@@ -12016,9 +12016,9 @@
"dev": true
},
"prettier": {
"version": "1.18.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-1.18.2.tgz",
"integrity": "sha512-OeHeMc0JhFE9idD4ZdtNibzY0+TPHSpSSb9h8FqtP+YnoZZ1sl8Vc9b1sasjfymH3SonAF4QcA2+mzHPhMvIiw==",
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz",
"integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==",
"dev": true
},
"prettier-linter-helpers": {

Some files were not shown because too many files have changed in this diff Show More