| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362 |
- import React, {
- useState, useRef, useImperativeHandle, useCallback, ForwardRefRenderFunction, forwardRef,
- memo,
- useEffect,
- } from 'react';
- import Dropzone from 'react-dropzone';
- import { useTranslation } from 'react-i18next';
- import {
- Modal, ModalHeader, ModalBody,
- } from 'reactstrap';
- import { toastError, toastSuccess } from '~/client/util/toastr';
- import { IEditorSettings } from '~/interfaces/editor-settings';
- import { useDefaultIndentSize } from '~/stores/context';
- import { useEditorSettings } from '~/stores/editor';
- import { useIsMobile } from '~/stores/ui';
- import { IEditorMethods } from '../../interfaces/editor-methods';
- import AbstractEditor from './AbstractEditor';
- import { Cheatsheet } from './Cheatsheet';
- import CodeMirrorEditor from './CodeMirrorEditor';
- import pasteHelper from './PasteHelper';
- import TextAreaEditor from './TextAreaEditor';
- import styles from './Editor.module.scss';
- export type EditorPropsType = {
- value?: string,
- isGfmMode?: boolean,
- noCdn?: boolean,
- isUploadable?: boolean,
- isUploadableFile?: boolean,
- onChange?: (newValue: string, isClean?: boolean) => void,
- onUpload?: (file) => void,
- editorSettings?: IEditorSettings,
- indentSize?: number,
- onDragEnter?: (event: any) => void,
- onMarkdownHelpButtonClicked?: () => void,
- onAddAttachmentButtonClicked?: () => void,
- onScroll?: (line: { line: number }) => void,
- onScrollCursorIntoView?: (line: number) => void,
- onSave?: () => Promise<void>,
- onPasteFiles?: (event: Event) => void,
- onCtrlEnter?: (event: Event) => void,
- isComment?: boolean,
- }
- type DropzoneRef = {
- open: () => void
- }
- const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props, ref): JSX.Element => {
- const {
- onUpload, isUploadable, isUploadableFile, indentSize, isGfmMode = true,
- } = props;
- const [dropzoneActive, setDropzoneActive] = useState(false);
- const [isUploading, setIsUploading] = useState(false);
- const [isCheatsheetModalShown, setIsCheatsheetModalShown] = useState(false);
- const [navBarItems, setNavBarItems] = useState<JSX.Element[]>([]);
- const { t } = useTranslation();
- const { data: editorSettings } = useEditorSettings();
- const { data: defaultIndentSize } = useDefaultIndentSize();
- const { data: isMobile } = useIsMobile();
- const dropzoneRef = useRef<DropzoneRef>(null);
- // CodeMirrorEditor ref
- const cmEditorRef = useRef<AbstractEditor<any>>(null);
- const taEditorRef = useRef<TextAreaEditor>(null);
- const editorSubstance = useCallback(() => {
- return isMobile ? taEditorRef.current : cmEditorRef.current;
- }, [isMobile]);
- // methods for ref
- useImperativeHandle(ref, () => ({
- forceToFocus: () => {
- editorSubstance()?.forceToFocus();
- },
- setValue: (newValue: string) => {
- editorSubstance()?.setValue(newValue);
- },
- setGfmMode: (bool: boolean) => {
- editorSubstance()?.setGfmMode(bool);
- },
- setCaretLine: (line: number) => {
- editorSubstance()?.setCaretLine(line);
- },
- setScrollTopByLine: (line: number) => {
- editorSubstance()?.setScrollTopByLine(line);
- },
- insertText: (text: string) => {
- editorSubstance()?.insertText(text);
- },
- /**
- * remove overlay and set isUploading to false
- */
- terminateUploadingState: () => {
- setDropzoneActive(false);
- setIsUploading(false);
- },
- }));
- /**
- * dispatch onUpload event
- */
- const dispatchUpload = useCallback((files) => {
- if (onUpload != null) {
- onUpload(files);
- }
- }, [onUpload]);
- /**
- * get acceptable(uploadable) file type
- */
- const getAcceptableType = useCallback(() => {
- let accept = 'null'; // reject all
- if (isUploadable) {
- if (!isUploadableFile) {
- accept = 'image/*'; // image only
- }
- else {
- accept = ''; // allow all
- }
- }
- return accept;
- }, [isUploadable, isUploadableFile]);
- const pasteFilesHandler = useCallback((event) => {
- const items = event.clipboardData.items || event.clipboardData.files || [];
- // abort if length is not 1
- if (items.length < 1) {
- return;
- }
- for (let i = 0; i < items.length; i++) {
- try {
- const file = items[i].getAsFile();
- // check file type (the same process as Dropzone)
- if (file != null && pasteHelper.isAcceptableType(file, getAcceptableType())) {
- dispatchUpload(file);
- setIsUploading(true);
- }
- }
- catch (e) {
- toastError(t('toaster.file_upload_failed'));
- }
- }
- }, [dispatchUpload, getAcceptableType, t]);
- const dragEnterHandler = useCallback((event) => {
- const dataTransfer = event.dataTransfer;
- // do nothing if contents is not files
- if (!dataTransfer.types.includes('Files')) {
- return;
- }
- setDropzoneActive(true);
- }, []);
- const dropHandler = useCallback((accepted) => {
- // rejected
- if (accepted.length !== 1) { // length should be 0 or 1 because `multiple={false}` is set
- setDropzoneActive(false);
- return;
- }
- const file = accepted[0];
- dispatchUpload(file);
- setIsUploading(true);
- }, [dispatchUpload]);
- const addAttachmentHandler = useCallback(() => {
- if (dropzoneRef.current == null) { return }
- dropzoneRef.current.open();
- }, []);
- const getDropzoneClassName = useCallback((isDragAccept: boolean, isDragReject: boolean) => {
- let className = 'dropzone';
- if (!isUploadable) {
- className += ' dropzone-unuploadable';
- }
- else {
- className += ' dropzone-uploadable';
- if (isUploadableFile) {
- className += ' dropzone-uploadablefile';
- }
- }
- // uploading
- if (isUploading) {
- className += ' dropzone-uploading';
- }
- if (isDragAccept) {
- className += ' dropzone-accepted';
- }
- if (isDragReject) {
- className += ' dropzone-rejected';
- }
- return className;
- }, [isUploadable, isUploading, isUploadableFile]);
- const renderDropzoneOverlay = useCallback(() => {
- return (
- <div className="overlay overlay-dropzone-active">
- {isUploading
- && (
- <span className="overlay-content">
- <div className="speeding-wheel d-inline-block"></div>
- <span className="sr-only">Uploading...</span>
- </span>
- )
- }
- {!isUploading && <span className="overlay-content"></span>}
- </div>
- );
- }, [isUploading]);
- const renderNavbar = () => {
- return (
- <div className="m-0 navbar navbar-default navbar-editor" data-testid="navbar-editor" style={{ minHeight: 'unset' }}>
- <ul className="pl-2 nav nav-navbar">
- { navBarItems.map((item, idx) => {
- // eslint-disable-next-line react/no-array-index-key
- return <li key={`navbarItem-${idx}`}>{item}</li>;
- }) }
- </ul>
- </div>
- );
- };
- const renderCheatsheetModal = useCallback(() => {
- const hideCheatsheetModal = () => {
- setIsCheatsheetModalShown(false);
- };
- return (
- <Modal isOpen={isCheatsheetModalShown} toggle={hideCheatsheetModal} className={`modal-gfm-cheatsheet ${styles['modal-gfm-cheatsheet']}`} size="lg">
- <ModalHeader tag="h4" toggle={hideCheatsheetModal} className="bg-primary text-light">
- <i className="icon-fw icon-question" />Markdown help
- </ModalHeader>
- <ModalBody>
- <Cheatsheet />
- </ModalBody>
- </Modal>
- );
- }, [isCheatsheetModalShown]);
- const isReadyToRenderEditor = editorSettings != null;
- // https://redmine.weseek.co.jp/issues/111731
- useEffect(() => {
- const editorRef = editorSubstance();
- if (isReadyToRenderEditor && editorRef != null) {
- const editorNavBarItems = editorRef.getNavbarItems() ?? [];
- setNavBarItems(editorNavBarItems);
- }
- }, [editorSubstance, isReadyToRenderEditor]);
- if (!isReadyToRenderEditor) {
- return <></>;
- }
- const flexContainer: React.CSSProperties = {
- height: '100%',
- display: 'flex',
- flexDirection: 'column',
- };
- return (
- <>
- <div style={flexContainer} className={`editor-container ${styles['editor-container']}`}>
- <Dropzone
- ref={dropzoneRef}
- accept={getAcceptableType()}
- noClick
- noKeyboard
- multiple={false}
- onDragLeave={() => { setDropzoneActive(false) }}
- onDrop={dropHandler}
- >
- {({
- getRootProps,
- getInputProps,
- isDragAccept,
- isDragReject,
- }) => {
- return (
- <div className={getDropzoneClassName(isDragAccept, isDragReject)} {...getRootProps()}>
- { dropzoneActive && renderDropzoneOverlay() }
- { renderNavbar() }
- {/* for PC */}
- { !isMobile && (
- <CodeMirrorEditor
- ref={cmEditorRef}
- indentSize={indentSize ?? defaultIndentSize}
- onPasteFiles={pasteFilesHandler}
- onDragEnter={dragEnterHandler}
- onMarkdownHelpButtonClicked={() => { setIsCheatsheetModalShown(true) }}
- onAddAttachmentButtonClicked={addAttachmentHandler}
- editorSettings={editorSettings}
- isGfmMode={isGfmMode}
- {...props}
- />
- )}
- {/* for mobile */}
- { isMobile && (
- <TextAreaEditor
- ref={taEditorRef}
- onPasteFiles={pasteFilesHandler}
- onDragEnter={dragEnterHandler}
- {...props}
- />
- )}
- <input {...getInputProps()} />
- </div>
- );
- }}
- </Dropzone>
- { isUploadable
- && (
- <button
- type="button"
- className="btn btn-outline-secondary btn-block btn-open-dropzone"
- onClick={addAttachmentHandler}
- >
- <i className="icon-paper-clip" aria-hidden="true"></i>
- Attach files
- <span className="d-none d-sm-inline">
- by dragging & dropping,
- <span className="btn-link">selecting them</span>,
- or pasting from the clipboard.
- </span>
- </button>
- )
- }
- { renderCheatsheetModal() }
- </div>
- </>
- );
- };
- export default memo(forwardRef(Editor));
|