diff --git a/packages/editor/core/src/hooks/use-editor.tsx b/packages/editor/core/src/hooks/use-editor.tsx index c2923c1e9..e38f009f7 100644 --- a/packages/editor/core/src/hooks/use-editor.tsx +++ b/packages/editor/core/src/hooks/use-editor.tsx @@ -17,6 +17,7 @@ interface CustomEditorProps { description_html: string; }; deleteFile: DeleteImage; + getAsset: any; cancelUploadImage?: () => any; setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void; setShouldShowAlert?: (showAlert: boolean) => void; @@ -35,6 +36,7 @@ export const useEditor = ({ uploadFile, deleteFile, cancelUploadImage, + getAsset, editorProps = {}, value, rerenderOnPropsChange, @@ -62,7 +64,8 @@ export const useEditor = ({ }, deleteFile, restoreFile, - cancelUploadImage + cancelUploadImage, + getAsset ), ...extensions, ], @@ -80,6 +83,7 @@ export const useEditor = ({ [rerenderOnPropsChange] ); + console.log("yoooooooooo", editor?.getHTML()); const editorRef: MutableRefObject = useRef(null); editorRef.current = editor; diff --git a/packages/editor/core/src/lib/editor-commands.ts b/packages/editor/core/src/lib/editor-commands.ts index 4a56f07c2..2aefb16bf 100644 --- a/packages/editor/core/src/lib/editor-commands.ts +++ b/packages/editor/core/src/lib/editor-commands.ts @@ -123,7 +123,7 @@ export const insertImageCommand = ( if (input.files?.length) { const file = input.files[0]; const pos = editor.view.state.selection.from; - startImageUpload(file, editor.view, pos, uploadFile, setIsSubmitting); + startImageUpload(file, editor.view, pos, uploadFile, setIsSubmitting, editor); } }; input.click(); diff --git a/packages/editor/core/src/ui/components/editor-content.tsx b/packages/editor/core/src/ui/components/editor-content.tsx index 9c0938788..17d6a0036 100644 --- a/packages/editor/core/src/ui/components/editor-content.tsx +++ b/packages/editor/core/src/ui/components/editor-content.tsx @@ -11,7 +11,7 @@ interface EditorContentProps { export const EditorContentWrapper = ({ editor, editorContentCustomClassNames = "", children }: EditorContentProps) => (
- {editor?.isActive("image") && editor?.isEditable && } + {/* {editor?.isActive("image") && editor?.isEditable && } */} {children}
); diff --git a/packages/editor/core/src/ui/extensions/image/image.tsx b/packages/editor/core/src/ui/extensions/image/image.tsx new file mode 100644 index 000000000..f9ac73408 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/image/image.tsx @@ -0,0 +1,347 @@ +import { NodeViewWrapper, type NodeViewProps, ReactNodeViewRenderer, mergeAttributes } from "@tiptap/react"; +import { type CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; +import ImageExt from "@tiptap/extension-image"; +import { DeleteImage } from "src/types/delete-image"; +import { RestoreImage } from "src/types/restore-image"; +import { onNodeDeleted, onNodeRestored } from "src/ui/plugins/delete-image"; +import { UploadImagesPlugin } from "src/ui/plugins/upload-image"; +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; + +interface ImageNode extends ProseMirrorNode { + attrs: { + src: string; + id: string; + }; +} + +const deleteKey = new PluginKey("delete-image"); +const IMAGE_NODE_TYPE = "image"; +const useEvent = any>(handler: T): T => { + const handlerRef = useRef(null); + + useLayoutEffect(() => { + handlerRef.current = handler; + }, [handler]); + + return useCallback((...args: Parameters): ReturnType => { + if (handlerRef.current === null) { + throw new Error("Handler is not assigned"); + } + return handlerRef.current(...args); + }, []) as T; +}; + +const MIN_WIDTH = 60; +const BORDER_COLOR = "#0096fd"; + +export const ResizableImageTemplate = ({ node, updateAttributes, getAsset }: NodeViewProps & { getAsset: any }) => { + const containerRef = useRef(null); + const imgRef = useRef(null); + const [editing, setEditing] = useState(false); + const [resizingStyle, setResizingStyle] = useState | undefined>(); + + const [loading, setLoading] = useState(true); + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setEditing(false); + } + }; + document.addEventListener("click", handleClickOutside); + return () => { + document.removeEventListener("click", handleClickOutside); + }; + }, [editing]); + + const [src, setSrc] = useState(""); + + useEffect(() => { + const fetchImageBlob = async () => { + setLoading(true); // Start loading + try { + const blob = await getAsset?.(node.attrs.assetId); + const imageUrl = URL.createObjectURL(blob); + setSrc(imageUrl); + } catch (error) { + console.error("Error fetching image:", error); + } finally { + setLoading(false); // Stop loading regardless of the outcome + } + }; + if (node.attrs.assetId) { + fetchImageBlob(); + } + }, [node.attrs.assetId, getAsset]); + + const handleMouseDown = useEvent((event: React.MouseEvent) => { + if (!imgRef.current) return; + event.preventDefault(); + const direction = event.currentTarget.dataset.direction || "--"; + const initialXPosition = event.clientX; + const currentWidth = imgRef.current.width; + let newWidth = currentWidth; + const transform = direction[1] === "w" ? -1 : 1; + + const removeListeners = () => { + window.removeEventListener("mousemove", mouseMoveHandler); + window.removeEventListener("mouseup", removeListeners); + updateAttributes({ width: newWidth }); + setResizingStyle(undefined); + }; + + const mouseMoveHandler = (event: MouseEvent) => { + newWidth = Math.max(currentWidth + transform * (event.clientX - initialXPosition), MIN_WIDTH); + setResizingStyle({ width: newWidth }); + // If mouse is up, remove event listeners + if (!event.buttons) removeListeners(); + }; + + window.addEventListener("mousemove", mouseMoveHandler); + window.addEventListener("mouseup", removeListeners); + }); + + const dragCornerButton = (direction: string) => ( +
+ ); + console.log("image node", loading); + return ( + setEditing(true)} + onBlur={() => setEditing(false)} + > +
+ {loading ? ( +
+ {/* Example loading spinner using Tailwind CSS */} +
+ + Loading... +
+
+ ) : ( + + )} + {editing && ( + <> + {[ + { left: 0, top: 0, height: "100%", width: "1px" }, + { right: 0, top: 0, height: "100%", width: "1px" }, + { top: 0, left: 0, width: "100%", height: "1px" }, + { bottom: 0, left: 0, width: "100%", height: "1px" }, + ].map((style, i) => ( +
+ ))} + {dragCornerButton("nw")} + {dragCornerButton("ne")} + {dragCornerButton("sw")} + {dragCornerButton("se")} + + )} +
+ + ); +}; + +export const ImageExtension = ( + deleteImage: DeleteImage, + restoreFile: RestoreImage, + cancelUploadImage?: () => any, + getAsset?: any +) => + ImageExt.extend({ + addProseMirrorPlugins() { + return [ + UploadImagesPlugin(cancelUploadImage), + new Plugin({ + key: deleteKey, + appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { + const newImageSources = new Set(); + newState.doc.descendants((node) => { + if (node.type.name === IMAGE_NODE_TYPE) { + newImageSources.add(node.attrs.src); + } + }); + + transactions.forEach((transaction) => { + // transaction could be a selection + if (!transaction.docChanged) return; + + const removedImages: ImageNode[] = []; + + // iterate through all the nodes in the old state + oldState.doc.descendants((oldNode) => { + // if the node is not an image, then return as no point in checking + if (oldNode.type.name !== IMAGE_NODE_TYPE) return; + + // Check if the node has been deleted or replaced + if (!newImageSources.has(oldNode.attrs.src)) { + removedImages.push(oldNode as ImageNode); + } + }); + + removedImages.forEach(async (node) => { + const src = node.attrs.src; + this.storage.images.set(src, true); + await onNodeDeleted(src, deleteImage); + }); + }); + + return null; + }, + }), + new Plugin({ + key: new PluginKey("imageRestoration"), + appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { + const oldImageSources = new Set(); + oldState.doc.descendants((node) => { + if (node.type.name === IMAGE_NODE_TYPE) { + oldImageSources.add(node.attrs.src); + } + }); + + transactions.forEach((transaction) => { + if (!transaction.docChanged) return; + + const addedImages: ImageNode[] = []; + + newState.doc.descendants((node, pos) => { + if (node.type.name !== IMAGE_NODE_TYPE) return; + if (pos < 0 || pos > newState.doc.content.size) return; + if (oldImageSources.has(node.attrs.src)) return; + addedImages.push(node as ImageNode); + }); + + addedImages.forEach(async (image) => { + const wasDeleted = this.storage.images.get(image.attrs.src); + if (wasDeleted === undefined) { + this.storage.images.set(image.attrs.src, false); + } else if (wasDeleted === true) { + await onNodeRestored(image.attrs.src, restoreFile); + } + }); + }); + return null; + }, + }), + ]; + }, + + onCreate(this) { + const imageSources = new Set(); + this.editor.state.doc.descendants((node) => { + if (node.type.name === IMAGE_NODE_TYPE) { + imageSources.add(node.attrs.src); + } + }); + imageSources.forEach(async (src) => { + try { + const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); + await restoreFile(assetUrlWithWorkspaceId); + } catch (error) { + console.error("Error restoring image: ", error); + } + }); + }, + + // storage to keep track of image states Map + addStorage() { + return { + images: new Map(), + }; + }, + + addNodeView() { + return ReactNodeViewRenderer((props: Object) => ); + }, + + parseHTML() { + return [ + { + tag: "image-component", // Assuming your images are represented by tags with a specific attribute + getAttrs: (node: string | HTMLElement) => { + if (typeof node === "string") { + return null; + } + return { + assetId: node.getAttribute("assetId") || null, + src: node.getAttribute("src"), + alt: node.getAttribute("alt") || "", + title: node.getAttribute("title") || "", + }; + }, + }, + ]; + }, + + draggable: true, + renderHTML({ HTMLAttributes }) { + return ["image-component", mergeAttributes(HTMLAttributes)]; + }, + addAttributes() { + return { + // ...this.parent?.(), + assetId: { + default: null, + }, + width: { + default: "35%", + }, + height: { + default: null, + }, + }; + }, + }).configure({ inline: true }); diff --git a/packages/editor/core/src/ui/extensions/index.tsx b/packages/editor/core/src/ui/extensions/index.tsx index 5bfba3b0f..6b4d273d5 100644 --- a/packages/editor/core/src/ui/extensions/index.tsx +++ b/packages/editor/core/src/ui/extensions/index.tsx @@ -11,7 +11,7 @@ import { TableCell } from "src/ui/extensions/table/table-cell/table-cell"; import { TableHeader } from "src/ui/extensions/table/table-header/table-header"; import { TableRow } from "src/ui/extensions/table/table-row/table-row"; -import { ImageExtension } from "src/ui/extensions/image"; +import { ImageExtension } from "src/ui/extensions/image/image"; import { isValidHttpUrl } from "src/lib/utils"; import { Mentions } from "src/ui/mentions"; @@ -34,7 +34,8 @@ export const CoreEditorExtensions = ( }, deleteFile: DeleteImage, restoreFile: RestoreImage, - cancelUploadImage?: () => any + cancelUploadImage?: () => any, + getAsset?: any ) => [ StarterKit.configure({ bulletList: { @@ -79,7 +80,7 @@ export const CoreEditorExtensions = ( "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", }, }), - ImageExtension(deleteFile, restoreFile, cancelUploadImage).configure({ + ImageExtension(deleteFile, restoreFile, cancelUploadImage, getAsset).configure({ HTMLAttributes: { class: "rounded-lg border border-custom-border-300", }, diff --git a/packages/editor/core/src/ui/plugins/upload-image.tsx b/packages/editor/core/src/ui/plugins/upload-image.tsx index 738653d71..08baeb29e 100644 --- a/packages/editor/core/src/ui/plugins/upload-image.tsx +++ b/packages/editor/core/src/ui/plugins/upload-image.tsx @@ -1,4 +1,6 @@ import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; +import { Editor, Range } from "@tiptap/core"; + import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view"; import { UploadImage } from "src/types/upload-image"; @@ -78,7 +80,8 @@ export async function startImageUpload( view: EditorView, pos: number, uploadFile: UploadImage, - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void + setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void, + editor?: Editor ) { if (!file) { alert("No file selected. Please select a file to upload."); @@ -123,17 +126,15 @@ export async function startImageUpload( setIsSubmitting?.("submitting"); try { - const src = await UploadImageHandler(file, uploadFile); - const { schema } = view.state; - pos = findPlaceholder(view.state, id); + const assetId = await UploadImageHandler(file, uploadFile); + const attrs = { + src: "", + alt: "", + assetId, + }; - 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.insert(pos - 1, node).setMeta(uploadKey, { remove: { id } }); - - view.dispatch(transaction); + editor?.chain().focus().insertContent({ type: "image", attrs }).run(); + removePlaceholder(view, id); } catch (error) { console.error("Upload error: ", error); removePlaceholder(view, id); @@ -144,13 +145,8 @@ const UploadImageHandler = (file: File, uploadFile: UploadImage): Promise { try { - const imageUrl = await uploadFile(file); - - const image = new Image(); - image.src = imageUrl; - image.onload = () => { - resolve(imageUrl); - }; + const blob = await uploadFile(file); + resolve(blob); } catch (error) { if (error instanceof Error) { console.log(error.message); diff --git a/packages/editor/rich-text-editor/src/ui/index.tsx b/packages/editor/rich-text-editor/src/ui/index.tsx index 43c3f8f34..de6305153 100644 --- a/packages/editor/rich-text-editor/src/ui/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/index.tsx @@ -26,6 +26,7 @@ export type IRichTextEditor = { id: string; description_html: string; }; + getAsset: any; customClassName?: string; editorContentCustomClassNames?: string; onChange?: (json: any, html: string) => void; @@ -55,6 +56,7 @@ const RichTextEditor = ({ editorContentCustomClassNames, value, uploadFile, + getAsset, deleteFile, noBorder, cancelUploadImage, @@ -76,6 +78,7 @@ const RichTextEditor = ({ const editor = useEditor({ onChange, + getAsset, debouncedUpdatesEnabled, setIsSubmitting, setShouldShowAlert, diff --git a/web/components/issues/description-form.tsx b/web/components/issues/description-form.tsx index ca6d7e0e7..8a8eb005c 100644 --- a/web/components/issues/description-form.tsx +++ b/web/components/issues/description-form.tsx @@ -174,9 +174,10 @@ export const IssueDescriptionForm: FC = (props) => { !disabled ? ( { + async attachAssetToIssue(workspaceSlug: string, projectId: string, issueId: string, assetId: string): Promise { + const attachUrl = `/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/attachments/${assetId}`; + return this.get(attachUrl, { + responseType: "blob", + headers: this.getHeaders(), + }) + .then(async (response) => response?.data) + .catch((error) => { + console.log(error); + throw error?.response?.data; + }); + } + + getAttachAssetToIssueFile(workspaceSlug: string, projectId: string, issueId: string) { + return async (assetId: string) => { + try { + const data = await this.attachAssetToIssue(workspaceSlug, projectId, issueId, assetId); + return data as Blob; + } catch (e) { + console.error(e); + } + }; + } + + async uploadFile(workspaceSlug: string, projectId: string, issueId: string, file: FormData): Promise { this.cancelSource = axios.CancelToken.source(); return this.post(`/api/workspaces/${workspaceSlug}/file-assets/`, file, { headers: { @@ -48,7 +72,7 @@ export class FileService extends APIService { }, cancelToken: this.cancelSource.token, }) - .then((response) => response?.data) + .then(async (response) => response?.data.asset) .catch((error) => { if (axios.isCancel(error)) { console.log(error.message); @@ -63,15 +87,15 @@ export class FileService extends APIService { this.cancelSource.cancel("Upload cancelled"); } - getUploadFileFunction(workspaceSlug: string): (file: File) => Promise { + getUploadFileFunction(workspaceSlug: string, projectId: string, issueId: string): (file: File) => Promise { return async (file: File) => { try { const formData = new FormData(); formData.append("asset", file); formData.append("attributes", JSON.stringify({})); - const data = await this.uploadFile(workspaceSlug, formData); - return data.asset; + const data = await this.uploadFile(workspaceSlug, projectId, issueId, formData); + return data; } catch (e) { console.error(e); }