lbry-desktop/ui/component/common/form-components/form-field.jsx
Thomas Zarebczan 8d4a05157d Paste/drop images directly to markdown editor
Ticket: 1135

- Changed `FileDrop` to only cover the upper 20% of the page, otherwise it will clash with markdown image drop.
2022-04-01 12:36:49 -04:00

410 lines
14 KiB
JavaScript

// @flow
import 'easymde/dist/easymde.min.css';
import 'inline-attachment/src/inline-attachment';
import 'inline-attachment/src/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,
});
};
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>}
</>
);
};