Skip to content

Commit

Permalink
feat: Implement image pasting on Insight editor with automatic upload
Browse files Browse the repository at this point in the history
  • Loading branch information
LulaV14 committed Jan 27, 2022
1 parent ac8600b commit d4f1ae3
Show file tree
Hide file tree
Showing 5 changed files with 110 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,19 @@
*/

import { useColorModeValue } from '@chakra-ui/react';
import { nanoid } from 'nanoid';
import { emoji } from 'node-emoji';
import { useEffect, useRef, useState } from 'react';
import AceEditor from 'react-ace';

import { UploadSingleFileMutation } from '../../models/generated/graphql';
import { useDebounce } from '../../shared/useDebounce';

import 'ace-builds/src-noconflict/ext-language_tools';
import 'ace-builds/src-noconflict/mode-markdown';
import 'ace-builds/src-noconflict/theme-chrome';
import 'ace-builds/src-noconflict/theme-nord_dark';

import { useDebounce } from '../../shared/useDebounce';

let emojiList: Record<string, unknown>[] | undefined = undefined;

const emojiCompleter = {
Expand Down Expand Up @@ -65,19 +68,55 @@ const emojiCompleter = {
interface Props {
contents: string;
onContentsChange: (updatedValue: string) => any;
uploadFile?: (file: File, name: string) => Promise<UploadSingleFileMutation | undefined>;
}

export const MarkdownEditor = ({ contents, onContentsChange }: Props) => {
export const MarkdownEditor = ({ contents, onContentsChange, uploadFile }: Props) => {
const aceTheme = useColorModeValue('chrome', 'nord_dark');

const [internalValue, setInternalValue] = useState(contents);
const previousValueRef = useRef(contents);

const aceEditorRef: any = useRef();

// Overwrite internal state if external contents change
useEffect(() => {
setInternalValue(contents);
}, [contents]);

// Adding listener for the aceEditor on 'paste' event
useEffect(() => {
async function handlePasteEvent(arg: any) {
const items = (arg.clipboardData || arg.originalEvent.clipboardData).items;

// Handle all image types on paste event
if (items[0].type.startsWith('image/')) {
const file = items[0].getAsFile();

// Set the proper image name by generating a random name if it has a default name (eg: 'image.png')
// or add `<` and `>` characters to the name in case it contains any spaces
const name = file.name.startsWith('image.')
? file.name.replace('image', `pasted-${nanoid().substring(0, 6)}`)
: file.name;

// Insert the image with Markdown notation based on the current cursor position
const cursorPos = aceEditorRef.current.editor.getCursorPosition();
aceEditorRef.current.editor.session.insert(cursorPos, `![${name}](${name.includes(' ') ? `<${name}>` : name})`);

if (uploadFile) {
await uploadFile(file, name);
}
}
}

if (uploadFile && aceEditorRef && aceEditorRef.current) {
document.querySelector(`#aceEditor`)?.addEventListener('paste', handlePasteEvent, true);
return function cleanup() {
document.querySelector(`#aceEditor`)?.removeEventListener('paste', handlePasteEvent, true);
};
}
});

// Debounce value changes to avoid too-frequent updates
useDebounce(
() => {
Expand All @@ -93,6 +132,8 @@ export const MarkdownEditor = ({ contents, onContentsChange }: Props) => {

return (
<AceEditor
name="aceEditor"
ref={aceEditorRef}
mode="markdown"
theme={aceTheme}
editorProps={{ $blockScrolling: true }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
} from '@chakra-ui/react';
import { ReactNode, useEffect, useRef, useState } from 'react';

import { UploadSingleFileMutation } from '../../models/generated/graphql';
import { iconFactory, iconFactoryAs } from '../../shared/icon-factory';
import { useDebounce } from '../../shared/useDebounce';
import { Link } from '../link/link';
Expand Down Expand Up @@ -117,6 +118,7 @@ interface Props {
showFormattingHelp?: boolean;
showPreview?: boolean;
transformAssetUri?: ((uri: string, children?: ReactNode, title?: string, alt?: string) => string) | null;
uploadFile?: (file: File, name: string) => Promise<UploadSingleFileMutation | undefined>;
}

export const MarkdownSplitEditor = ({
Expand All @@ -127,6 +129,7 @@ export const MarkdownSplitEditor = ({
showFormattingHelp = true,
showPreview = true,
transformAssetUri,
uploadFile,
...flexProps
}: Props & Omit<FlexProps, 'onChange'>) => {
const [internalValue, setInternalValue] = useState(contents);
Expand Down Expand Up @@ -196,7 +199,7 @@ export const MarkdownSplitEditor = ({
width="100%"
display={{ base: isPreviewMode ? 'none' : 'block', xl: 'block' }}
>
<MarkdownEditor contents={contents} onContentsChange={setInternalValue} />
<MarkdownEditor contents={contents} onContentsChange={setInternalValue} uploadFile={uploadFile} />
</Box>
{showPreview && (
<MarkdownContainer
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { FileViewer } from '../../../../components/file-viewer/file-viewer';
import { HtmlSplitEditor } from '../../../../components/html-split-editor/html-split-editor';
import { MarkdownSplitEditor } from '../../../../components/markdown-split-editor/markdown-split-editor';
import { InsightFile, InsightFileAction } from '../../../../models/file-tree';
import { Insight, InsightFileInput } from '../../../../models/generated/graphql';
import { Insight, InsightFileInput, UploadSingleFileMutation } from '../../../../models/generated/graphql';
import {
getLanguageForMime,
getMimeForFileName,
Expand Down Expand Up @@ -92,6 +92,7 @@ interface Props {
onFileChange: (updatedFile: InsightFileInput) => void;
insight: Insight;
transformAssetUri: (uri: string) => string;
uploadFile: (file: File, name: string) => Promise<UploadSingleFileMutation | undefined>;
}

export const InsightFileEditor = ({
Expand All @@ -101,6 +102,7 @@ export const InsightFileEditor = ({
onFileChange,
insight,
transformAssetUri,
uploadFile,
...flexProps
}: Props & FlexProps) => {
const [cachedFile, setCachedFile] = useState<InsightFile>(file);
Expand Down Expand Up @@ -143,6 +145,7 @@ export const InsightFileEditor = ({
baseAssetUrl={baseAssetUrl}
baseLinkUrl={baseLinkUrl}
transformAssetUri={transformAssetUri}
uploadFile={uploadFile}
flexGrow={1}
overflow="auto"
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@

import { useToast } from '@chakra-ui/react';
import isEqual from 'lodash/isEqual';
import { nanoid } from 'nanoid';
import { useState } from 'react';
import { gql, useMutation } from 'urql';

import { Alert } from '../../components/alert/alert';
import { Insight, UpdatedInsight } from '../../models/generated/graphql';
import { Insight, UpdatedInsight, UploadSingleFileMutation } from '../../models/generated/graphql';
import { urqlClient } from '../../urql';

import { InsightEditor } from './insight-editor';

Expand Down Expand Up @@ -70,6 +72,18 @@ const DRAFT_PUBLISH_MUTATION = gql`
}
`;

const UPLOAD_SINGLE_FILE_MUTATION = gql`
mutation UploadSingleFile($draftKey: String!, $attachment: InsightFileUploadInput!, $file: Upload!) {
uploadSingleFile(draftKey: $draftKey, attachment: $attachment, file: $file) {
id
name
path
mimeType
size
}
}
`;

/**
* This component is rendered only after the following are available:
* - Insight
Expand All @@ -88,6 +102,34 @@ export const InsightDraftContainer = ({ insight, draft, draftKey, onRefresh }) =
const [upsertDraftResult, upsertDraft] = useMutation(DRAFT_UPSERT_MUTATION);
const { error: upsertDraftError, fetching: isSavingDraft } = upsertDraftResult;

// UploadFile method for pasting images
const uploadFile = async (file: File, name: string): Promise<UploadSingleFileMutation | undefined> => {
// Upload file to IEX storage
const { data, error } = await urqlClient
.mutation<UploadSingleFileMutation>(UPLOAD_SINGLE_FILE_MUTATION, {
draftKey,
attachment: {
id: nanoid(),
size: file.size,
name
},
file: file
})
.toPromise();

if (error || data === undefined) {
toast({
position: 'bottom-right',
title: 'Unable to upload pasted image.',
status: 'error',
duration: 9000,
isClosable: true
});
}

return data;
};

// Save Drafts periodically, but only on changes
// Calls to this callback should be throttled to avoid saving too often
const saveDraft = async (draftData: DraftDataInput): Promise<boolean> => {
Expand Down Expand Up @@ -176,6 +218,7 @@ export const InsightDraftContainer = ({ insight, draft, draftKey, onRefresh }) =
isSavingDraft={isSavingDraft}
isPublishing={isPublishing}
onRefresh={onRefresh}
uploadFile={uploadFile}
/>
</>
);
Expand Down
16 changes: 14 additions & 2 deletions packages/frontend/src/pages/insight-editor/insight-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import urljoin from 'url-join';
import { gql, useMutation, useQuery } from 'urql';

import { FileOrFolder, InsightFileAction } from '../../models/file-tree';
import { Insight, InsightFileInput } from '../../models/generated/graphql';
import { Insight, InsightFileInput, UploadSingleFileMutation } from '../../models/generated/graphql';
import { InsightFileTree, isFile } from '../../shared/file-tree';
import { isRelativeUrl } from '../../shared/url-utils';
import { useDebounce } from '../../shared/useDebounce';
Expand Down Expand Up @@ -104,6 +104,7 @@ interface Props {
isPublishing: boolean;
isSavingDraft: boolean;
onRefresh: () => void;
uploadFile: (file: File, name: string) => Promise<UploadSingleFileMutation | undefined>;
}

/**
Expand All @@ -114,7 +115,7 @@ interface Props {
* This component should not be rendered until `insight` and `draft` have been loaded.
*/
export const InsightEditor = memo(
({ insight, draftKey, draft, saveDraft, publish, isSavingDraft, isPublishing, onRefresh }: Props) => {
({ insight, draftKey, draft, saveDraft, publish, isSavingDraft, isPublishing, onRefresh, uploadFile }: Props) => {
const navigate = useNavigate();
const toast = useToast();

Expand Down Expand Up @@ -330,6 +331,16 @@ export const InsightEditor = memo(
return uri;
};

const uploadFileWrapper = async (file: File, name: string): Promise<UploadSingleFileMutation | undefined> => {
const data = await uploadFile(file, name);
if (data) {
fileTree.addItem({ ...data?.uploadSingleFile, action: InsightFileAction.ADD });
fileTreeChange(fileTree);
}

return data;
};

return (
<Flex
as="form"
Expand Down Expand Up @@ -408,6 +419,7 @@ export const InsightEditor = memo(
baseAssetUrl={`/api/v1/insights/${insight.fullName}/assets`}
baseLinkUrl={`/${itemType}/${insight.fullName}/files`}
transformAssetUri={transformAssetUri}
uploadFile={uploadFileWrapper}
flexGrow={1}
overflow="auto"
/>
Expand Down

0 comments on commit d4f1ae3

Please sign in to comment.