From c078d59916f75755a6d3229ec75441c4ec56785b Mon Sep 17 00:00:00 2001 From: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com> Date: Tue, 8 Aug 2023 16:20:19 +0530 Subject: [PATCH] Image uploads done --- .../command-palette/command-pallette.tsx | 2 +- apps/app/components/issues/extensions.tsx | 9 +- .../issues/plugins/UploadHelper.tsx | 28 +++++ .../issues/plugins/upload-image.tsx | 119 ++++++++++++++++++ apps/app/components/issues/props.ts | 54 ++++++++ apps/app/components/issues/props.tsx | 18 --- apps/app/components/issues/tiptap.tsx | 5 +- .../projects/[projectId]/issues/[issueId].tsx | 1 + 8 files changed, 213 insertions(+), 23 deletions(-) create mode 100644 apps/app/components/issues/plugins/UploadHelper.tsx create mode 100644 apps/app/components/issues/plugins/upload-image.tsx create mode 100644 apps/app/components/issues/props.ts delete mode 100644 apps/app/components/issues/props.tsx diff --git a/apps/app/components/command-palette/command-pallette.tsx b/apps/app/components/command-palette/command-pallette.tsx index 0d5b37ada..151002c9e 100644 --- a/apps/app/components/command-palette/command-pallette.tsx +++ b/apps/app/components/command-palette/command-pallette.tsx @@ -82,7 +82,7 @@ export const CommandPalette: React.FC = () => { !(e.target instanceof HTMLTextAreaElement) && !(e.target instanceof HTMLInputElement) && // !(e.target as Element).classList?.contains("remirror-editor") && - !(e.target as Element)?.closest(".tiptap-editor-container") + (e.target === document || (e.target instanceof Element && !e.target.closest(".tiptap-editor-container"))) ) { if ((ctrlKey || metaKey) && keyPressed === "k") { e.preventDefault(); diff --git a/apps/app/components/issues/extensions.tsx b/apps/app/components/issues/extensions.tsx index 403495fa4..f34d32fe4 100644 --- a/apps/app/components/issues/extensions.tsx +++ b/apps/app/components/issues/extensions.tsx @@ -18,9 +18,16 @@ import { InputRule } from "@tiptap/core"; import ts from 'highlight.js/lib/languages/typescript' import 'highlight.js/styles/github-dark.css'; +import UploadImagesPlugin from "./plugins/upload-image"; lowlight.registerLanguage('ts', ts) +const CustomImage = TiptapImage.extend({ + addProseMirrorPlugins() { + return [UploadImagesPlugin()]; + }, +}); + export const TiptapExtensions = [ StarterKit.configure({ bulletList: { @@ -92,7 +99,7 @@ export const TiptapExtensions = [ "text-stone-400 underline underline-offset-[3px] hover:text-stone-600 transition-colors cursor-pointer", }, }), - TiptapImage.configure({ + CustomImage.configure({ allowBase64: true, HTMLAttributes: { class: "rounded-lg border border-stone-200", diff --git a/apps/app/components/issues/plugins/UploadHelper.tsx b/apps/app/components/issues/plugins/UploadHelper.tsx new file mode 100644 index 000000000..5a64d4c0a --- /dev/null +++ b/apps/app/components/issues/plugins/UploadHelper.tsx @@ -0,0 +1,28 @@ +import fileService from 'services/file.service'; + +const UploadImageHandler = (file: File): Promise => { + try { + const formData = new FormData(); + formData.append("asset", file); + formData.append("attributes", JSON.stringify({})); + + return new Promise(async (resolve, reject) => { + const imageUrl = await fileService + .uploadFile("plane", formData) + .then((response) => response.asset); + + console.log(imageUrl, "imageurl") + const image = new Image(); + image.src = imageUrl; + image.onload = () => { + resolve(imageUrl); + }; + }) + } + catch (error) { + console.log(error) + return Promise.reject(error); + } +}; + +export default UploadImageHandler; diff --git a/apps/app/components/issues/plugins/upload-image.tsx b/apps/app/components/issues/plugins/upload-image.tsx new file mode 100644 index 000000000..b1ea94cc2 --- /dev/null +++ b/apps/app/components/issues/plugins/upload-image.tsx @@ -0,0 +1,119 @@ +import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; +import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view"; +import fileService from "services/file.service"; + +const uploadKey = new PluginKey("upload-image"); + +const UploadImagesPlugin = () => + new Plugin({ + key: uploadKey, + state: { + init() { + return DecorationSet.empty; + }, + apply(tr, set) { + set = set.map(tr.mapping, tr.doc); + // See if the transaction adds or removes any placeholders + const action = tr.getMeta(this); + if (action && action.add) { + const { id, pos, src } = action.add; + + const placeholder = document.createElement("div"); + placeholder.setAttribute("class", "img-placeholder"); + const image = document.createElement("img"); + image.setAttribute( + "class", + "opacity-10 rounded-lg border border-stone-200", + ); + image.src = src; + placeholder.appendChild(image); + const deco = Decoration.widget(pos + 1, placeholder, { + id, + }); + set = set.add(tr.doc, [deco]); + } else if (action && action.remove) { + set = set.remove( + set.find(null, null, (spec) => spec.id == action.remove.id), + ); + } + return set; + }, + }, + props: { + decorations(state) { + return this.getState(state); + }, + }, + }); + +export default UploadImagesPlugin; + +function findPlaceholder(state: EditorState, id: {}) { + const decos = uploadKey.getState(state); + const found = decos.find(null, null, (spec) => spec.id == id); + return found.length ? found[0].from : null; +} + +export async function startImageUpload(file: File, view: EditorView, pos: number) { + if (!file.type.includes("image/")) { + return; + } else if (file.size / 1024 / 1024 > 20) { + return; + } + + const id = {}; + + const tr = view.state.tr; + if (!tr.selection.empty) tr.deleteSelection(); + + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => { + tr.setMeta(uploadKey, { + add: { + id, + pos, + src: reader.result, + }, + }); + view.dispatch(tr); + }; + + const src = await UploadImageHandler(file); + console.log(src, "src") + const { schema } = view.state; + pos = findPlaceholder(view.state, id); + + if (pos == null) return; + const imageSrc = typeof src === "object" ? reader.result : src; + + const node = schema.nodes.image.create({ src: imageSrc }); + const transaction = view.state.tr + .replaceWith(pos, pos, node) + .setMeta(uploadKey, { remove: { id } }); + view.dispatch(transaction); +} + +const UploadImageHandler = (file: File): Promise => { + try { + const formData = new FormData(); + formData.append("asset", file); + formData.append("attributes", JSON.stringify({})); + + return new Promise(async (resolve, reject) => { + const imageUrl = await fileService + .uploadFile("plane", formData) + .then((response) => response.asset); + + const image = new Image(); + image.src = imageUrl; + image.onload = () => { + resolve(imageUrl); + }; + }) + } + catch (error) { + console.log(error) + return Promise.reject(error); + } +}; diff --git a/apps/app/components/issues/props.ts b/apps/app/components/issues/props.ts new file mode 100644 index 000000000..ea7f71b59 --- /dev/null +++ b/apps/app/components/issues/props.ts @@ -0,0 +1,54 @@ +import { EditorProps } from "@tiptap/pm/view"; +import { startImageUpload } from "./plugins/upload-image"; + +export const TiptapEditorProps: EditorProps = { + attributes: { + class: `prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none`, + }, + handleDOMEvents: { + keydown: (_view, event) => { + // prevent default event listeners from firing when slash command is active + if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) { + const slashCommand = document.querySelector("#slash-command"); + if (slashCommand) { + return true; + } + } + }, + }, + handlePaste: (view, event) => { + if ( + event.clipboardData && + event.clipboardData.files && + event.clipboardData.files[0] + ) { + event.preventDefault(); + const file = event.clipboardData.files[0]; + const pos = view.state.selection.from; + + startImageUpload(file, view, pos); + return true; + } + return false; + }, + handleDrop: (view, event, _slice, moved) => { + if ( + !moved && + event.dataTransfer && + event.dataTransfer.files && + event.dataTransfer.files[0] + ) { + event.preventDefault(); + const file = event.dataTransfer.files[0]; + const coordinates = view.posAtCoords({ + left: event.clientX, + top: event.clientY, + }); + // here we deduct 1 from the pos or else the image will create an extra node + startImageUpload(file, view, coordinates.pos - 1); + return true; + } + return false; + }, +}; + diff --git a/apps/app/components/issues/props.tsx b/apps/app/components/issues/props.tsx deleted file mode 100644 index 6470b37c1..000000000 --- a/apps/app/components/issues/props.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { EditorProps } from "@tiptap/pm/view"; - -export const TiptapEditorProps: EditorProps = { - attributes: { - class: `prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none`, - }, - handleDOMEvents: { - keydown: (_view, event) => { - // prevent default event listeners from firing when slash command is active - if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) { - const slashCommand = document.querySelector("#slash-command"); - if (slashCommand) { - return true; - } - } - }, - }, -}; diff --git a/apps/app/components/issues/tiptap.tsx b/apps/app/components/issues/tiptap.tsx index 67380a742..ddadc9d13 100644 --- a/apps/app/components/issues/tiptap.tsx +++ b/apps/app/components/issues/tiptap.tsx @@ -1,9 +1,8 @@ -import { useEditor, EditorContent, generateText } from '@tiptap/react'; -import StarterKit from '@tiptap/starter-kit'; +import { useEditor, EditorContent } from '@tiptap/react'; import { useDebouncedCallback } from 'use-debounce'; import { EditorBubbleMenu } from './EditorBubbleMenu'; import { TiptapExtensions } from './extensions'; -import { TiptapEditorProps } from "./props"; +import { TiptapEditorProps } from './props'; type TiptapProps = { value: string; diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx index d5f7c8ec6..cf0aa5de7 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx @@ -42,6 +42,7 @@ const defaultValues = { const IssueDetailsPage: NextPage = () => { const router = useRouter(); const { workspaceSlug, projectId, issueId } = router.query; + console.log(workspaceSlug, "workspaceSlug") const { user } = useUserAuth();