a61f943465
(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.
411 lines
14 KiB
JavaScript
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>}
|
|
</>
|
|
);
|
|
};
|