Просмотр исходного кода

Merge pull request #8103 from weseek/imprv/126528-128886-attach-some-files

imprv: Support to attach some files  in editor
reiji-h 2 лет назад
Родитель
Сommit
d7a23d7cc4

+ 58 - 57
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -294,67 +294,67 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   }, [isNotFound, mutateEditorMode, router, save, t, updateStateAfterSave]);
 
 
-  /**
-   * the upload event handler
-   * @param {any} file
-   */
-  const uploadHandler = useCallback(async(file) => {
-    try {
-      // eslint-disable-next-line @typescript-eslint/no-explicit-any
-      let res: any = await apiGet('/attachments.limit', {
-        fileSize: file.size,
-      });
-
-      if (!res.isUploadable) {
-        throw new Error(res.errorMessage);
+  // the upload event handler
+  const uploadHandler = useCallback((files: File[]) => {
+    files.forEach(async(file) => {
+      try {
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        const resLimit: any = await apiGet('/attachments.limit', {
+          fileSize: file.size,
+        });
+
+        if (!resLimit.isUploadable) {
+          throw new Error(resLimit.errorMessage);
+        }
+
+        const formData = new FormData();
+        formData.append('file', file);
+        if (currentPagePath != null) {
+          formData.append('path', currentPagePath);
+        }
+        if (pageId != null) {
+          formData.append('page_id', pageId);
+        }
+        if (pageId == null) {
+          formData.append('page_body', codeMirrorEditor?.getDoc() ?? '');
+        }
+
+        const resAdd: any = await apiPostForm('/attachments.add', formData);
+        const attachment = resAdd.attachment;
+        const fileName = attachment.originalName;
+
+        let insertText = `[${fileName}](${attachment.filePathProxied})\n`;
+        // when image
+        if (attachment.fileFormat.startsWith('image/')) {
+          // modify to "![fileName](url)" syntax
+          insertText = `!${insertText}`;
+        }
+        // TODO: implement
+        // refs: https://redmine.weseek.co.jp/issues/126528
+        // editorRef.current.insertText(insertText);
+        codeMirrorEditor?.insertText(insertText);
+
+        // when if created newly
+        // Not using 'mutateGrant' to inherit the grant of the parent page
+        if (resAdd.pageCreated) {
+          logger.info('Page is created', resAdd.page._id);
+          mutateIsLatestRevision(true);
+          setCreatedPageRevisionIdWithAttachment(resAdd.page.revision);
+          await mutateCurrentPageId(resAdd.page._id);
+          await mutateCurrentPage();
+        }
       }
-
-      const formData = new FormData();
-      // const { pageId, path } = pageContainer.state;
-      formData.append('file', file);
-      if (currentPagePath != null) {
-        formData.append('path', currentPagePath);
-      }
-      if (pageId != null) {
-        formData.append('page_id', pageId);
+      catch (e) {
+        logger.error('failed to upload', e);
+        toastError(e);
       }
-      if (pageId == null) {
-        formData.append('page_body', codeMirrorEditor?.getDoc() ?? '');
+      finally {
+        // TODO: implement
+        // refs: https://redmine.weseek.co.jp/issues/126528
+        // editorRef.current.terminateUploadingState();
       }
+    });
 
-      res = await apiPostForm('/attachments.add', formData);
-      const attachment = res.attachment;
-      const fileName = attachment.originalName;
-
-      let insertText = `[${fileName}](${attachment.filePathProxied})`;
-      // when image
-      if (attachment.fileFormat.startsWith('image/')) {
-        // modify to "![fileName](url)" syntax
-        insertText = `!${insertText}`;
-      }
-      // TODO: implement
-      // refs: https://redmine.weseek.co.jp/issues/126528
-      // editorRef.current.insertText(insertText);
-
-      // when if created newly
-      // Not using 'mutateGrant' to inherit the grant of the parent page
-      if (res.pageCreated) {
-        logger.info('Page is created', res.page._id);
-        mutateIsLatestRevision(true);
-        setCreatedPageRevisionIdWithAttachment(res.page.revision);
-        await mutateCurrentPageId(res.page._id);
-        await mutateCurrentPage();
-      }
-    }
-    catch (e) {
-      logger.error('failed to upload', e);
-      toastError(e);
-    }
-    finally {
-      // TODO: implement
-      // refs: https://redmine.weseek.co.jp/issues/126528
-      // editorRef.current.terminateUploadingState();
-    }
   }, [codeMirrorEditor, currentPagePath, mutateCurrentPage, mutateCurrentPageId, mutateIsLatestRevision, pageId]);
 
 
@@ -574,6 +574,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
         <CodeMirrorEditorMain
           onChange={markdownChangedHandler}
           onSave={saveWithShortcut}
+          onUpload={uploadHandler}
           indentSize={currentIndentSize ?? defaultIndentSize}
         />
       </div>

+ 1 - 0
packages/editor/package.json

@@ -33,6 +33,7 @@
     "codemirror": "^6.0.1",
     "eslint-plugin-react-refresh": "^0.4.1",
     "material-icons": "^1.13.10",
+    "react-dropzone": "^14.2.3",
     "react-hook-form": "^7.45.4",
     "react-toastify": "^9.1.3",
     "reactstrap": "^9.2.0",

+ 7 - 3
packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx

@@ -6,6 +6,7 @@ import { indentUnit } from '@codemirror/language';
 import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
 
 import { GlobalCodeMirrorEditorKey } from '../../consts';
+import { useFileDropzone } from '../../services';
 import { useCodeMirrorEditorIsolated } from '../../stores';
 
 import { Toolbar } from './Toolbar';
@@ -18,10 +19,10 @@ const CodeMirrorEditorContainer = forwardRef<HTMLDivElement>((props, ref) => {
   );
 });
 
-
 type Props = {
   editorKey: string | GlobalCodeMirrorEditorKey,
   onChange?: (value: string) => void,
+  onUpload?: (files: File[]) => void,
   indentSize?: number,
 }
 
@@ -29,6 +30,7 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
   const {
     editorKey,
     onChange,
+    onUpload,
     indentSize,
   } = props;
 
@@ -52,10 +54,12 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
 
   }, [codeMirrorEditor, indentSize]);
 
+  const { getRootProps, open } = useFileDropzone({ onUpload });
+
   return (
-    <div className="flex-expand-vert">
+    <div {...getRootProps()} className="flex-expand-vert">
       <CodeMirrorEditorContainer ref={containerRef} />
-      <Toolbar />
+      <Toolbar onFileOpen={open} />
     </div>
   );
 };

+ 9 - 3
packages/editor/src/components/CodeMirrorEditor/Toolbar/AttachmentsDropup.tsx

@@ -5,7 +5,13 @@ import {
   DropdownItem,
 } from 'reactstrap';
 
-export const AttachmentsDropup = (): JSX.Element => {
+type Props = {
+  onFileOpen: () => void,
+}
+
+export const AttachmentsDropup = (props: Props): JSX.Element => {
+
+  const { onFileOpen } = props;
   return (
     <>
       <UncontrolledDropdown direction="up" className="lh-1">
@@ -18,11 +24,11 @@ export const AttachmentsDropup = (): JSX.Element => {
             Attachments
           </DropdownItem>
           <DropdownItem divider />
-          <DropdownItem className="d-flex gap-1 align-items-center">
+          <DropdownItem className="d-flex gap-1 align-items-center" onClick={onFileOpen}>
             <span className="material-icons-outlined fs-5">attach_file</span>
             Files
           </DropdownItem>
-          <DropdownItem className="d-flex gap-1 align-items-center">
+          <DropdownItem className="d-flex gap-1 align-items-center" onClick={onFileOpen}>
             <span className="material-icons-outlined fs-5">image</span>
             Images
           </DropdownItem>

+ 8 - 2
packages/editor/src/components/CodeMirrorEditor/Toolbar/Toolbar.tsx

@@ -9,10 +9,16 @@ import { TextFormatTools } from './TextFormatTools';
 
 import styles from './Toolbar.module.scss';
 
-export const Toolbar = memo((): JSX.Element => {
+type Props = {
+  onFileOpen: () => void,
+}
+
+export const Toolbar = memo((props: Props): JSX.Element => {
+
+  const { onFileOpen } = props;
   return (
     <div className={`d-flex gap-2 p-2 codemirror-editor-toolbar ${styles['codemirror-editor-toolbar']}`}>
-      <AttachmentsDropup />
+      <AttachmentsDropup onFileOpen={onFileOpen} />
       <TextFormatTools />
       <EmojiButton />
       <TableButton />

+ 3 - 1
packages/editor/src/components/CodeMirrorEditorMain.tsx

@@ -17,12 +17,13 @@ const additionalExtensions: Extension[] = [
 type Props = {
   onChange?: (value: string) => void,
   onSave?: () => void,
+  onUpload?: (files: File[]) => void,
   indentSize?: number,
 }
 
 export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
   const {
-    onSave, onChange, indentSize,
+    onSave, onChange, onUpload, indentSize,
   } = props;
 
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
@@ -61,6 +62,7 @@ export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
     <CodeMirrorEditor
       editorKey={GlobalCodeMirrorEditorKey.MAIN}
       onChange={onChange}
+      onUpload={onUpload}
       indentSize={indentSize}
     />
   );

+ 14 - 0
packages/editor/src/components/playground/Playground.tsx

@@ -37,6 +37,18 @@ export const Playground = (): JSX.Element => {
     toast.success('Saved.', { autoClose: 2000 });
   }, [codeMirrorEditor]);
 
+  // the upload event handler
+  // demo of uploading a file.
+  const uploadHandler = useCallback((files: File[]) => {
+    files.forEach((file) => {
+      // set dummy file name.
+      const insertText = `[${file.name}](/attachment/aaaabbbbccccdddd)\n`;
+      codeMirrorEditor?.insertText(insertText);
+    });
+
+  }, [codeMirrorEditor]);
+
+
   return (
     <>
       <div className="flex-expand-vert justify-content-center align-items-center bg-dark" style={{ minHeight: '83px' }}>
@@ -47,6 +59,8 @@ export const Playground = (): JSX.Element => {
           <CodeMirrorEditorMain
             onSave={saveHandler}
             onChange={setMarkdownToPreview}
+            onUpload={uploadHandler}
+            indentSize={4}
           />
         </div>
         <div className="flex-expand-vert d-none d-lg-flex bg-light text-dark border-start border-dark-subtle p-3">

+ 8 - 0
packages/editor/src/services/codemirror-editor/use-codemirror-editor/use-codemirror-editor.ts

@@ -12,6 +12,8 @@ import { useAppendExtensions, type AppendExtensions } from './utils/append-exten
 import { useFocus, type Focus } from './utils/focus';
 import { useGetDoc, type GetDoc } from './utils/get-doc';
 import { useInitDoc, type InitDoc } from './utils/init-doc';
+import { useInsertText, type InsertText } from './utils/insert-text';
+import { useReplaceText, type ReplaceText } from './utils/replace-text';
 import { useSetCaretLine, type SetCaretLine } from './utils/set-caret-line';
 
 type UseCodeMirrorEditorUtils = {
@@ -20,6 +22,8 @@ type UseCodeMirrorEditorUtils = {
   getDoc: GetDoc,
   focus: Focus,
   setCaretLine: SetCaretLine,
+  insertText: InsertText,
+  replaceText: ReplaceText,
 }
 export type UseCodeMirrorEditor = {
   state: EditorState | undefined;
@@ -57,6 +61,8 @@ export const useCodeMirrorEditor = (props?: UseCodeMirror): UseCodeMirrorEditor
   const getDoc = useGetDoc(view);
   const focus = useFocus(view);
   const setCaretLine = useSetCaretLine(view);
+  const insertText = useInsertText(view);
+  const replaceText = useReplaceText(view);
 
   return {
     state,
@@ -66,5 +72,7 @@ export const useCodeMirrorEditor = (props?: UseCodeMirror): UseCodeMirrorEditor
     getDoc,
     focus,
     setCaretLine,
+    insertText,
+    replaceText,
   };
 };

+ 24 - 0
packages/editor/src/services/codemirror-editor/use-codemirror-editor/utils/insert-text.ts

@@ -0,0 +1,24 @@
+import { useCallback } from 'react';
+
+import { EditorView } from '@codemirror/view';
+
+export type InsertText = (text: string) => void;
+
+export const useInsertText = (view?: EditorView): InsertText => {
+
+  return useCallback((text) => {
+    if (view == null) {
+      return;
+    }
+    const insertPos = view.state.selection.main.head;
+    view.dispatch({
+      changes: {
+        from: insertPos,
+        to: insertPos,
+        insert: text,
+      },
+      selection: { anchor: insertPos },
+    });
+  }, [view]);
+
+};

+ 15 - 0
packages/editor/src/services/codemirror-editor/use-codemirror-editor/utils/replace-text.ts

@@ -0,0 +1,15 @@
+import { useCallback } from 'react';
+
+import { EditorView } from '@codemirror/view';
+
+export type ReplaceText = (text: string) => void;
+
+export const useReplaceText = (view?: EditorView): ReplaceText => {
+
+  return useCallback((text) => {
+    view?.dispatch(
+      view?.state.replaceSelection(text),
+    );
+  }, [view]);
+
+};

+ 1 - 0
packages/editor/src/services/file-dropzone/index.ts

@@ -0,0 +1 @@
+export * from './use-file-dropzone';

+ 27 - 0
packages/editor/src/services/file-dropzone/use-file-dropzone.ts

@@ -0,0 +1,27 @@
+import { useCallback } from 'react';
+
+import { useDropzone } from 'react-dropzone';
+import type { DropzoneState } from 'react-dropzone';
+
+type DropzoneEditor = {
+  onUpload?: (files: File[]) => void,
+}
+
+export const useFileDropzone = (props: DropzoneEditor): DropzoneState => {
+
+  const { onUpload } = props;
+
+  const dropHandler = useCallback((acceptedFiles: File[]) => {
+    if (onUpload == null) {
+      return;
+    }
+    onUpload(acceptedFiles);
+  }, [onUpload]);
+
+  return useDropzone({
+    noKeyboard: true,
+    noClick: true,
+    onDrop: dropHandler,
+  });
+
+};

+ 1 - 0
packages/editor/src/services/index.ts

@@ -1 +1,2 @@
 export * from './codemirror-editor';
+export * from './file-dropzone';

+ 17 - 1
yarn.lock

@@ -5519,7 +5519,7 @@ at-least-node@^1.0.0:
   resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2"
   integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==
 
-attr-accept@^2.2.1:
+attr-accept@^2.2.1, attr-accept@^2.2.2:
   version "2.2.2"
   resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.2.tgz#646613809660110749e92f2c10833b70968d929b"
   integrity sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==
@@ -8730,6 +8730,13 @@ file-selector@^0.2.2:
   dependencies:
     tslib "^2.0.3"
 
+file-selector@^0.6.0:
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.6.0.tgz#fa0a8d9007b829504db4d07dd4de0310b65287dc"
+  integrity sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==
+  dependencies:
+    tslib "^2.4.0"
+
 filelist@^1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.2.tgz#80202f21462d4d1c2e214119b1807c1bc0380e5b"
@@ -14172,6 +14179,15 @@ react-dropzone@^11.2.4:
     file-selector "^0.2.2"
     prop-types "^15.7.2"
 
+react-dropzone@^14.2.3:
+  version "14.2.3"
+  resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-14.2.3.tgz#0acab68308fda2d54d1273a1e626264e13d4e84b"
+  integrity sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==
+  dependencies:
+    attr-accept "^2.2.2"
+    file-selector "^0.6.0"
+    prop-types "^15.8.1"
+
 react-error-boundary@^3.1.4:
   version "3.1.4"
   resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0"