412 lines
14 KiB
JavaScript
412 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: false,
|
|
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
|
|
spellCheck
|
|
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>}
|
|
</>
|
|
);
|
|
};
|