lbry-desktop/ui/component/common/form-components/form-field.jsx
infinite-persistence a61f943465 inline-attachment: move to local copy + handle xhr errors
(1) Move from Node to local copy
(2) Handle xhr errors more gracefully. Instead of erasing the syntax, we should say that it failed through the URL.
2022-04-01 12:36:49 -04:00

411 lines
14 KiB
JavaScript

// @flow
import 'easymde/dist/easymde.min.css';
import './plugins/inline-attachment/inline-attachment';
import './plugins/inline-attachment/codemirror-4.inline-attachment';
import { IMG_CDN_PUBLISH_URL, JSON_RESPONSE_KEYS, UPLOAD_CONFIG } from 'constants/cdn_urls';
import { FF_MAX_CHARS_DEFAULT } from 'constants/form-field';
import { openEditorMenu, stopContextMenu } from 'util/context-menu';
import { lazyImport } from 'util/lazyImport';
import MarkdownPreview from 'component/common/markdown-preview';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import SimpleMDE from 'react-simplemde-editor';
import type { ElementRef } from 'react';
import { InputSimple, BlockWrapWrapper } from './input-simple';
import { InputSelect } from './input-select';
import { CountInfo, QuickAction, Label } from './common';
import { TextareaWrapper } from './slim-input-field';
// prettier-ignore
const TextareaWithSuggestions = lazyImport(() => import('component/textareaWithSuggestions' /* webpackChunkName: "suggestions" */));
type Props = {
uri?: string,
affixClass?: string, // class applied to prefix/postfix label
autoFocus?: boolean,
blockWrap: boolean,
charCount?: number,
children?: React$Node,
defaultValue?: string | number,
disabled?: boolean,
error?: string | boolean,
helper?: string | React$Node,
hideSuggestions?: boolean,
inputButton?: React$Node,
isLivestream?: boolean,
label?: any,
labelOnLeft: boolean,
max?: number,
min?: number,
name: string,
placeholder?: string | number,
postfix?: string,
prefix?: string,
quickActionLabel?: string,
range?: number,
readOnly?: boolean,
stretch?: boolean,
textAreaMaxLength?: number,
type?: string,
value?: string | number,
slimInput?: boolean,
slimInputButtonRef?: any,
commentSelectorsProps?: any,
showSelectors?: any,
submitButtonRef?: any,
tipModalOpen?: boolean,
noticeLabel?: any,
onSlimInputClose?: () => void,
onChange?: (any) => any,
setShowSelectors?: ({ tab?: string, open: boolean }) => void,
quickActionHandler?: (any) => any,
render?: () => React$Node,
handleTip?: (isLBC: boolean) => any,
handleSubmit?: () => any,
};
type State = {
drawerOpen: boolean,
};
export class FormField extends React.PureComponent<Props, State> {
static defaultProps = { labelOnLeft: false, blockWrap: true };
input: { current: ElementRef<any> };
constructor(props: Props) {
super(props);
this.input = React.createRef();
this.state = {
drawerOpen: false,
};
}
componentDidMount() {
const { autoFocus, showSelectors, slimInput } = this.props;
const input = this.input.current;
if (input && autoFocus) input.focus();
if (slimInput && showSelectors && showSelectors.open && input) input.blur();
}
render() {
const {
uri,
affixClass,
autoFocus,
blockWrap,
charCount,
children,
error,
helper,
hideSuggestions,
inputButton,
isLivestream,
label,
labelOnLeft,
name,
postfix,
prefix,
quickActionLabel,
stretch,
textAreaMaxLength,
type,
slimInput,
slimInputButtonRef,
commentSelectorsProps,
showSelectors,
submitButtonRef,
tipModalOpen,
noticeLabel,
onSlimInputClose,
quickActionHandler,
setShowSelectors,
render,
handleTip,
handleSubmit,
...inputProps
} = this.props;
const errorMessage = typeof error === 'object' ? error.message : error;
const wrapperProps = { type, helper };
const labelProps = { name, label };
const countInfoProps = { charCount, textAreaMaxLength };
const quickActionProps = { label: quickActionLabel, quickActionHandler };
const inputSimpleProps = { name, label, ...inputProps };
const inputSelectProps = { name, error, label, children, ...inputProps };
switch (type) {
case 'radio':
return (
<FormFieldWrapper {...wrapperProps}>
<BlockWrapWrapper blockWrap={blockWrap}>
<InputSimple {...inputSimpleProps} type="radio" />
</BlockWrapWrapper>
</FormFieldWrapper>
);
case 'checkbox':
return (
<FormFieldWrapper {...wrapperProps}>
<div className="checkbox">
<InputSimple {...inputSimpleProps} type="checkbox" />
</div>
</FormFieldWrapper>
);
case 'range':
return (
<FormFieldWrapper {...wrapperProps}>
<div className="range">
<InputSimple {...inputSimpleProps} type="range" />
</div>
</FormFieldWrapper>
);
case 'select':
return (
<FormFieldWrapper {...wrapperProps}>
<InputSelect {...inputSelectProps} />
</FormFieldWrapper>
);
case 'select-tiny':
return (
<FormFieldWrapper {...wrapperProps}>
<InputSelect {...inputSelectProps} className="select--slim" />
</FormFieldWrapper>
);
case 'markdown':
const handleEvents = { contextmenu: openEditorMenu };
const getInstance = (editor) => {
// SimpleMDE max char check
editor.codemirror.on('beforeChange', (instance, changes) => {
if (textAreaMaxLength && changes.update) {
let str = changes.text.join('\n');
let 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'));
}
}
});
// "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://)';
// 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)
});
// Add ability to upload pasted/dragged image (https://github.com/sparksuite/simplemde-markdown-editor/issues/328#issuecomment-227075500)
window.inlineAttachment.editors.codemirror4.attach(editor.codemirror, {
uploadUrl: IMG_CDN_PUBLISH_URL,
uploadFieldName: UPLOAD_CONFIG.BLOB_KEY,
extraParams: { [UPLOAD_CONFIG.ACTION_KEY]: UPLOAD_CONFIG.ACTION_VAL },
filenameTag: '{filename}',
urlText: '![image]({filename})',
jsonFieldName: JSON_RESPONSE_KEYS.UPLOADED_URL,
errorText: '![image]("failed to upload file")',
});
};
return (
<FormFieldWrapper {...wrapperProps}>
<div className="form-field--SimpleMDE" onContextMenu={stopContextMenu}>
<fieldset-section>
<div className="form-field__two-column">
<div>
<Label {...labelProps} />
</div>
<QuickAction {...quickActionProps} />
</div>
<SimpleMDE
{...inputProps}
id={name}
type="textarea"
events={handleEvents}
getMdeInstance={getInstance}
options={{
spellChecker: true,
hideIcons: ['heading', 'image', 'fullscreen', 'side-by-side'],
status: [
{
className: 'editor-statusbar__upload-hint',
defaultValue: (el) => {
el.innerHTML = __('Attach images by pasting or drag-and-drop.');
},
},
'lines',
'words',
'cursor',
],
previewRender(plainText) {
const preview = <MarkdownPreview content={plainText} noDataStore />;
return ReactDOMServer.renderToString(preview);
},
}}
/>
<CountInfo {...countInfoProps} />
</fieldset-section>
</div>
</FormFieldWrapper>
);
case 'textarea':
const closeSelector =
setShowSelectors && showSelectors
? () => setShowSelectors({ tab: showSelectors.tab || undefined, open: false })
: () => {};
return (
<FormFieldWrapper {...wrapperProps}>
<fieldset-section>
<TextareaWrapper
isDrawerOpen={Boolean(this.state.drawerOpen)}
toggleDrawer={() => this.setState({ drawerOpen: !this.state.drawerOpen })}
closeSelector={closeSelector}
commentSelectorsProps={commentSelectorsProps}
showSelectors={Boolean(showSelectors && showSelectors.open)}
slimInput={slimInput}
slimInputButtonRef={slimInputButtonRef}
onSlimInputClose={onSlimInputClose}
tipModalOpen={tipModalOpen}
>
{(!slimInput || this.state.drawerOpen) && label && (
<div className="form-field__two-column">
<Label {...labelProps} />
<QuickAction {...quickActionProps} />
<CountInfo {...countInfoProps} />
</div>
)}
{noticeLabel}
{hideSuggestions ? (
<textarea
type={type}
id={name}
maxLength={textAreaMaxLength || FF_MAX_CHARS_DEFAULT}
ref={this.input}
{...inputProps}
/>
) : (
<React.Suspense fallback={null}>
<TextareaWithSuggestions
uri={uri}
type={type}
id={name}
maxLength={textAreaMaxLength || FF_MAX_CHARS_DEFAULT}
inputRef={this.input}
isLivestream={isLivestream}
toggleSelectors={
setShowSelectors && showSelectors
? () => {
const input = this.input.current;
if (!showSelectors.open && input) input.blur();
setShowSelectors({ tab: showSelectors.tab || undefined, open: !showSelectors.open });
}
: undefined
}
handleTip={handleTip}
handleSubmit={() => {
if (handleSubmit) handleSubmit();
if (slimInput) this.setState({ drawerOpen: false });
closeSelector();
}}
claimIsMine={commentSelectorsProps && commentSelectorsProps.claimIsMine}
{...inputProps}
slimInput={slimInput}
handlePreventClick={
!this.state.drawerOpen ? () => this.setState({ drawerOpen: true }) : undefined
}
autoFocus={this.state.drawerOpen && (!showSelectors || !showSelectors.open)}
submitButtonRef={submitButtonRef}
/>
</React.Suspense>
)}
</TextareaWrapper>
</fieldset-section>
</FormFieldWrapper>
);
default:
const inputElementProps = { type, name, ref: this.input, ...inputProps };
return (
<FormFieldWrapper {...wrapperProps}>
<fieldset-section>
{(label || errorMessage) && <Label {...labelProps} errorMessage={errorMessage} />}
{prefix && <label htmlFor={name}>{prefix}</label>}
{inputButton ? (
<input-submit>
<input {...inputElementProps} />
{inputButton}
</input-submit>
) : (
<input {...inputElementProps} />
)}
</fieldset-section>
</FormFieldWrapper>
);
}
}
}
export default FormField;
type WrapperProps = {
type?: string,
children?: any,
helper?: any,
};
const FormFieldWrapper = (wrapperProps: WrapperProps) => {
const { type, children, helper } = wrapperProps;
return (
<>
{type && children}
{helper && <div className="form-field__help">{helper}</div>}
</>
);
};