From ba3eecb97d8dcc9f4d7cf9ccefdaafd40b2e5cde Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Wed, 22 Jan 2025 16:22:01 +0530 Subject: [PATCH 1/7] refactor: editor file handling --- .../editors/document/read-only-editor.tsx | 10 +- .../components/image-uploader.tsx | 1 + .../extensions/custom-image/custom-image.ts | 17 ++- .../custom-image/read-only-custom-image.ts | 4 +- .../core/extensions/image/read-only-image.tsx | 4 +- .../core/extensions/read-only-extensions.tsx | 14 +-- .../editor/src/core/hooks/use-file-upload.ts | 5 +- .../src/core/hooks/use-read-only-editor.ts | 4 +- .../editor/src/core/types/collaboration.ts | 3 +- packages/editor/src/core/types/config.ts | 8 +- packages/editor/src/core/types/editor.ts | 9 +- packages/editor/src/core/types/image.ts | 2 +- .../pages/(detail)/[pageId]/page.tsx | 32 +++-- .../pages/editor/ai/ask-pi-menu.tsx | 7 ++ web/ce/components/pages/editor/ai/menu.tsx | 4 +- .../core/modals/gpt-assistant-popover.tsx | 12 +- .../lite-text-editor/lite-text-editor.tsx | 13 +- .../lite-text-read-only-editor.tsx | 9 +- .../rich-text-editor/rich-text-editor.tsx | 13 +- .../rich-text-read-only-editor.tsx | 9 +- .../editor/sticky-editor/editor.tsx | 12 +- .../modals/create-modal/issue-description.tsx | 22 ++-- .../components/issues/description-input.tsx | 35 +++--- .../issue-activity/comments/comment-card.tsx | 5 +- .../comments/comment-create.tsx | 4 +- .../issue-detail/issue-activity/root.tsx | 34 +++--- .../components/description-editor.tsx | 30 ++--- .../components/pages/editor/editor-body.tsx | 9 +- web/core/components/pages/version/editor.tsx | 11 +- .../profile/activity/activity-list.tsx | 6 +- .../activity/profile-activity-list.tsx | 3 +- web/core/hooks/editor/index.ts | 2 + web/core/hooks/editor/use-editor-config.ts | 96 +++++++++++++++ .../hooks/{ => editor}/use-editor-mention.tsx | 0 web/core/hooks/store/index.ts | 1 + web/core/hooks/store/use-editor-asset.ts | 10 ++ web/core/services/file.service.ts | 19 ++- web/core/store/editor/asset.store.ts | 113 ++++++++++++++++++ web/core/store/root.store.ts | 5 +- web/helpers/editor.helper.ts | 89 -------------- 40 files changed, 438 insertions(+), 248 deletions(-) create mode 100644 web/core/hooks/editor/index.ts create mode 100644 web/core/hooks/editor/use-editor-config.ts rename web/core/hooks/{ => editor}/use-editor-mention.tsx (100%) create mode 100644 web/core/hooks/store/use-editor-asset.ts create mode 100644 web/core/store/editor/asset.store.ts diff --git a/packages/editor/src/core/components/editors/document/read-only-editor.tsx b/packages/editor/src/core/components/editors/document/read-only-editor.tsx index 0e8ab63f8a8..ef75da02c60 100644 --- a/packages/editor/src/core/components/editors/document/read-only-editor.tsx +++ b/packages/editor/src/core/components/editors/document/read-only-editor.tsx @@ -10,7 +10,13 @@ import { getEditorClassNames } from "@/helpers/common"; // hooks import { useReadOnlyEditor } from "@/hooks/use-read-only-editor"; // types -import { EditorReadOnlyRefApi, TDisplayConfig, TExtensions, TFileHandler, TReadOnlyMentionHandler } from "@/types"; +import { + EditorReadOnlyRefApi, + TDisplayConfig, + TExtensions, + TReadOnlyFileHandler, + TReadOnlyMentionHandler, +} from "@/types"; interface IDocumentReadOnlyEditor { disabledExtensions: TExtensions[]; @@ -20,7 +26,7 @@ interface IDocumentReadOnlyEditor { displayConfig?: TDisplayConfig; editorClassName?: string; embedHandler: any; - fileHandler: Pick; + fileHandler: TReadOnlyFileHandler; tabIndex?: number; handleEditorReady?: (value: boolean) => void; mentionHandler: TReadOnlyMentionHandler; diff --git a/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx b/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx index eaea423878d..a54ade78768 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx @@ -68,6 +68,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { ); // hooks const { uploading: isImageBeingUploaded, uploadFile } = useUploader({ + blockId: imageEntityId ?? "", editor, loadImageFromFileSystem, maxFileSize, diff --git a/packages/editor/src/core/extensions/custom-image/custom-image.ts b/packages/editor/src/core/extensions/custom-image/custom-image.ts index 3b64db8d098..e8121ee5dfd 100644 --- a/packages/editor/src/core/extensions/custom-image/custom-image.ts +++ b/packages/editor/src/core/extensions/custom-image/custom-image.ts @@ -4,12 +4,12 @@ import { ReactNodeViewRenderer } from "@tiptap/react"; import { v4 as uuidv4 } from "uuid"; // extensions import { CustomImageNode } from "@/extensions/custom-image"; +// helpers +import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary"; // plugins import { TrackImageDeletionPlugin, TrackImageRestorationPlugin, isFileValid } from "@/plugins/image"; // types import { TFileHandler } from "@/types"; -// helpers -import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary"; export type InsertImageComponentProps = { file?: File; @@ -21,7 +21,7 @@ declare module "@tiptap/core" { interface Commands { imageComponent: { insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType; - uploadImage: (file: File) => () => Promise | undefined; + uploadImage: (blockId: string, file: File) => () => Promise | undefined; getImageSource?: (path: string) => () => Promise; restoreImage: (src: string) => () => Promise; }; @@ -105,7 +105,6 @@ export const CustomImageExtension = (props: TFileHandler) => { this.editor.state.doc.descendants((node) => { if (node.type.name === this.name) { if (!node.attrs.src?.startsWith("http")) return; - imageSources.add(node.attrs.src); } }); @@ -134,7 +133,7 @@ export const CustomImageExtension = (props: TFileHandler) => { addCommands() { return { insertImageComponent: - (props: { file?: File; pos?: number; event: "insert" | "drop" }) => + (props) => ({ commands }) => { // Early return if there's an invalid file being dropped if ( @@ -182,12 +181,12 @@ export const CustomImageExtension = (props: TFileHandler) => { attrs: attributes, }); }, - uploadImage: (file: File) => async () => { - const fileUrl = await upload(file); + uploadImage: (blockId, file) => async () => { + const fileUrl = await upload(blockId, file); return fileUrl; }, - getImageSource: (path: string) => async () => await getAssetSrc(path), - restoreImage: (src: string) => async () => { + getImageSource: (path) => async () => await getAssetSrc(path), + restoreImage: (src) => async () => { await restoreImageFn(src); }, }; diff --git a/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts b/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts index c27970d9287..928d074d2bb 100644 --- a/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts +++ b/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts @@ -4,9 +4,9 @@ import { ReactNodeViewRenderer } from "@tiptap/react"; // components import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions/custom-image"; // types -import { TFileHandler } from "@/types"; +import { TReadOnlyFileHandler } from "@/types"; -export const CustomReadOnlyImageExtension = (props: Pick) => { +export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => { const { getAssetSrc } = props; return Image.extend, UploadImageExtensionStorage>({ diff --git a/packages/editor/src/core/extensions/image/read-only-image.tsx b/packages/editor/src/core/extensions/image/read-only-image.tsx index ce1581a8eb3..a656078037c 100644 --- a/packages/editor/src/core/extensions/image/read-only-image.tsx +++ b/packages/editor/src/core/extensions/image/read-only-image.tsx @@ -3,9 +3,9 @@ import { ReactNodeViewRenderer } from "@tiptap/react"; // extensions import { CustomImageNode } from "@/extensions"; // types -import { TFileHandler } from "@/types"; +import { TReadOnlyFileHandler } from "@/types"; -export const ReadOnlyImageExtension = (props: Pick) => { +export const ReadOnlyImageExtension = (props: TReadOnlyFileHandler) => { const { getAssetSrc } = props; return Image.extend({ diff --git a/packages/editor/src/core/extensions/read-only-extensions.tsx b/packages/editor/src/core/extensions/read-only-extensions.tsx index e39973f9c90..8711b2cb38a 100644 --- a/packages/editor/src/core/extensions/read-only-extensions.tsx +++ b/packages/editor/src/core/extensions/read-only-extensions.tsx @@ -27,14 +27,14 @@ import { } from "@/extensions"; // helpers import { isValidHttpUrl } from "@/helpers/common"; -// types -import { TExtensions, TFileHandler, TReadOnlyMentionHandler } from "@/types"; // plane editor extensions import { CoreReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions"; +// types +import { TExtensions, TReadOnlyFileHandler, TReadOnlyMentionHandler } from "@/types"; type Props = { disabledExtensions: TExtensions[]; - fileHandler: Pick; + fileHandler: TReadOnlyFileHandler; mentionHandler: TReadOnlyMentionHandler; }; @@ -94,16 +94,12 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => { }, }), CustomTypographyExtension, - ReadOnlyImageExtension({ - getAssetSrc: fileHandler.getAssetSrc, - }).configure({ + ReadOnlyImageExtension(fileHandler).configure({ HTMLAttributes: { class: "rounded-md", }, }), - CustomReadOnlyImageExtension({ - getAssetSrc: fileHandler.getAssetSrc, - }), + CustomReadOnlyImageExtension(fileHandler), TiptapUnderline, TextStyle, TaskList.configure({ diff --git a/packages/editor/src/core/hooks/use-file-upload.ts b/packages/editor/src/core/hooks/use-file-upload.ts index 65daa2f8e49..7d3dc7eaed9 100644 --- a/packages/editor/src/core/hooks/use-file-upload.ts +++ b/packages/editor/src/core/hooks/use-file-upload.ts @@ -6,6 +6,7 @@ import { insertImagesSafely } from "@/extensions/drop"; import { isFileValid } from "@/plugins/image"; type TUploaderArgs = { + blockId: string; editor: Editor; loadImageFromFileSystem: (file: string) => void; maxFileSize: number; @@ -13,7 +14,7 @@ type TUploaderArgs = { }; export const useUploader = (args: TUploaderArgs) => { - const { editor, loadImageFromFileSystem, maxFileSize, onUpload } = args; + const { blockId, editor, loadImageFromFileSystem, maxFileSize, onUpload } = args; // states const [uploading, setUploading] = useState(false); @@ -49,7 +50,7 @@ export const useUploader = (args: TUploaderArgs) => { reader.readAsDataURL(fileWithTrimmedName); // @ts-expect-error - TODO: fix typings, and don't remove await from // here for now - const url: string = await editor?.commands.uploadImage(fileWithTrimmedName); + const url: string = await editor?.commands.uploadImage(blockId, fileWithTrimmedName); if (!url) { throw new Error("Something went wrong while uploading the image"); diff --git a/packages/editor/src/core/hooks/use-read-only-editor.ts b/packages/editor/src/core/hooks/use-read-only-editor.ts index 12d4960b690..87b9c335d42 100644 --- a/packages/editor/src/core/hooks/use-read-only-editor.ts +++ b/packages/editor/src/core/hooks/use-read-only-editor.ts @@ -11,7 +11,7 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node"; // props import { CoreReadOnlyEditorProps } from "@/props"; // types -import type { EditorReadOnlyRefApi, TExtensions, TFileHandler, TReadOnlyMentionHandler } from "@/types"; +import type { EditorReadOnlyRefApi, TExtensions, TReadOnlyFileHandler, TReadOnlyMentionHandler } from "@/types"; interface CustomReadOnlyEditorProps { disabledExtensions: TExtensions[]; @@ -20,7 +20,7 @@ interface CustomReadOnlyEditorProps { extensions?: Extensions; forwardedRef?: MutableRefObject; initialValue?: string; - fileHandler: Pick; + fileHandler: TReadOnlyFileHandler; handleEditorReady?: (value: boolean) => void; mentionHandler: TReadOnlyMentionHandler; provider?: HocuspocusProvider; diff --git a/packages/editor/src/core/types/collaboration.ts b/packages/editor/src/core/types/collaboration.ts index c69a003fc9c..82e2f81f9a3 100644 --- a/packages/editor/src/core/types/collaboration.ts +++ b/packages/editor/src/core/types/collaboration.ts @@ -9,6 +9,7 @@ import { TExtensions, TFileHandler, TMentionHandler, + TReadOnlyFileHandler, TReadOnlyMentionHandler, TRealtimeConfig, TUserDetails, @@ -43,7 +44,7 @@ export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & { }; export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & { - fileHandler: Pick; + fileHandler: TReadOnlyFileHandler; forwardedRef?: React.MutableRefObject; mentionHandler: TReadOnlyMentionHandler; }; diff --git a/packages/editor/src/core/types/config.ts b/packages/editor/src/core/types/config.ts index 3bb4d1af292..0e5a547fa32 100644 --- a/packages/editor/src/core/types/config.ts +++ b/packages/editor/src/core/types/config.ts @@ -1,11 +1,15 @@ import { DeleteImage, RestoreImage, UploadImage } from "@/types"; -export type TFileHandler = { +export type TReadOnlyFileHandler = { getAssetSrc: (path: string) => Promise; + restore: RestoreImage; +}; + +export type TFileHandler = TReadOnlyFileHandler & { + getAssetUploadStatus: (blockId: string) => number; cancel: () => void; delete: DeleteImage; upload: UploadImage; - restore: RestoreImage; validation: { /** * @description max file size in bytes diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index 648cdf859fd..a675b35dc98 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -16,6 +16,7 @@ import { TExtensions, TFileHandler, TMentionHandler, + TReadOnlyFileHandler, TReadOnlyMentionHandler, TServerHandler, } from "@/types"; @@ -44,12 +45,16 @@ export type TEditorCommands = | "text-color" | "background-color" | "text-align" - | "callout"; + | "callout" + | "attachment"; export type TCommandExtraProps = { image: { savedSelection: Selection | null; }; + attachment: { + savedSelection: Selection | null; + }; "text-color": { color: string | undefined; }; @@ -154,7 +159,7 @@ export interface IReadOnlyEditorProps { disabledExtensions: TExtensions[]; displayConfig?: TDisplayConfig; editorClassName?: string; - fileHandler: Pick; + fileHandler: TReadOnlyFileHandler; forwardedRef?: React.MutableRefObject; id: string; initialValue: string; diff --git a/packages/editor/src/core/types/image.ts b/packages/editor/src/core/types/image.ts index 5c707bf33dd..ca6f76fb1b8 100644 --- a/packages/editor/src/core/types/image.ts +++ b/packages/editor/src/core/types/image.ts @@ -2,4 +2,4 @@ export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise; export type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise; -export type UploadImage = (file: File) => Promise; +export type UploadImage = (blockId: string, file: File) => Promise; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx index 8d7c5135725..c22b12ee2dd 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx @@ -17,19 +17,14 @@ import { LogoSpinner } from "@/components/common"; import { PageHead } from "@/components/core"; import { IssuePeekOverview } from "@/components/issues"; import { PageRoot, TPageRootConfig, TPageRootHandlers } from "@/components/pages"; -// helpers -import { getEditorFileHandlers } from "@/helpers/editor.helper"; // hooks -import { useProjectPage, useProjectPages, useWorkspace } from "@/hooks/store"; -// plane web hooks -import { useFileSize } from "@/plane-web/hooks/use-file-size"; +import { useEditorConfig } from "@/hooks/editor"; +import { useEditorAsset, useProjectPage, useProjectPages, useWorkspace } from "@/hooks/store"; // plane web services import { WorkspaceService } from "@/plane-web/services"; // services -import { FileService } from "@/services/file.service"; import { ProjectPageService, ProjectPageVersionService } from "@/services/page"; const workspaceService = new WorkspaceService(); -const fileService = new FileService(); const projectPageService = new ProjectPageService(); const projectPageVersionService = new ProjectPageVersionService(); @@ -39,6 +34,7 @@ const PageDetailsPage = observer(() => { const { createPage, getPageById } = useProjectPages(); const page = useProjectPage(pageId?.toString() ?? ""); const { getWorkspaceBySlug } = useWorkspace(); + const { uploadEditorAsset } = useEditorAsset(); // derived values const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "") : ""; const { canCurrentUserAccessPage, id, name, updateDescription } = page; @@ -51,8 +47,8 @@ const PageDetailsPage = observer(() => { }), [projectId, workspaceSlug] ); - // file size - const { maxFileSize } = useFileSize(); + // editor config + const { getEditorFileHandlers } = useEditorConfig(); // fetch page details const { error: pageDetailsError } = useSWR( workspaceSlug && projectId && pageId ? `PAGE_DETAILS_${pageId}` : null, @@ -96,18 +92,18 @@ const PageDetailsPage = observer(() => { const pageRootConfig: TPageRootConfig = useMemo( () => ({ fileHandler: getEditorFileHandlers({ - maxFileSize, projectId: projectId?.toString() ?? "", - uploadFile: async (file) => { - const { asset_id } = await fileService.uploadProjectAsset( - workspaceSlug?.toString() ?? "", - projectId?.toString() ?? "", - { + uploadFile: async (blockId, file) => { + const { asset_id } = await uploadEditorAsset({ + blockId, + data: { entity_identifier: id ?? "", entity_type: EFileAssetType.PAGE_DESCRIPTION, }, - file - ); + file, + projectId: projectId?.toString() ?? "", + workspaceSlug: workspaceSlug?.toString() ?? "", + }); return asset_id; }, workspaceId, @@ -119,7 +115,7 @@ const PageDetailsPage = observer(() => { workspaceSlug: workspaceSlug?.toString() ?? "", }, }), - [id, maxFileSize, projectId, workspaceId, workspaceSlug] + [getEditorFileHandlers, id, projectId, uploadEditorAsset, workspaceId, workspaceSlug] ); if ((!page || !id) && !pageDetailsError) diff --git a/web/ce/components/pages/editor/ai/ask-pi-menu.tsx b/web/ce/components/pages/editor/ai/ask-pi-menu.tsx index b9d6c85ef57..bd49942ef34 100644 --- a/web/ce/components/pages/editor/ai/ask-pi-menu.tsx +++ b/web/ce/components/pages/editor/ai/ask-pi-menu.tsx @@ -6,6 +6,8 @@ import { Tooltip } from "@plane/ui"; import { RichTextReadOnlyEditor } from "@/components/editor"; // helpers import { cn } from "@/helpers/common.helper"; +// hooks +import { useWorkspace } from "@/hooks/store"; type Props = { handleInsertText: (insertOnNextLine: boolean) => void; @@ -19,6 +21,10 @@ export const AskPiMenu: React.FC = (props) => { const { handleInsertText, handleRegenerate, isRegenerating, response, workspaceSlug } = props; // states const [query, setQuery] = useState(""); + // store hooks + const { getWorkspaceBySlug } = useWorkspace(); + // derived values + const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id ?? ""; return ( <> @@ -40,6 +46,7 @@ export const AskPiMenu: React.FC = (props) => { initialValue={response} containerClassName="!p-0 border-none" editorClassName="!pl-0" + workspaceId={workspaceId} workspaceSlug={workspaceSlug} />
diff --git a/web/ce/components/pages/editor/ai/menu.tsx b/web/ce/components/pages/editor/ai/menu.tsx index 036eaf28395..cef586a7712 100644 --- a/web/ce/components/pages/editor/ai/menu.tsx +++ b/web/ce/components/pages/editor/ai/menu.tsx @@ -21,6 +21,7 @@ type Props = { editorRef: RefObject; isOpen: boolean; onClose: () => void; + workspaceId: string; workspaceSlug: string; }; @@ -58,7 +59,7 @@ const TONES_LIST = [ ]; export const EditorAIMenu: React.FC = (props) => { - const { editorRef, isOpen, onClose, workspaceSlug } = props; + const { editorRef, isOpen, onClose, workspaceId, workspaceSlug } = props; // states const [activeTask, setActiveTask] = useState(null); const [response, setResponse] = useState(undefined); @@ -215,6 +216,7 @@ export const EditorAIMenu: React.FC = (props) => { initialValue={response} containerClassName="!p-0 border-none" editorClassName="!pl-0" + workspaceId={workspaceId} workspaceSlug={workspaceSlug} />
diff --git a/web/core/components/core/modals/gpt-assistant-popover.tsx b/web/core/components/core/modals/gpt-assistant-popover.tsx index 0056977ed6b..db3e10e2a14 100644 --- a/web/core/components/core/modals/gpt-assistant-popover.tsx +++ b/web/core/components/core/modals/gpt-assistant-popover.tsx @@ -6,12 +6,15 @@ import { Controller, useForm } from "react-hook-form"; // services import { usePopper } from "react-popper"; import { AlertCircle } from "lucide-react"; import { Popover, Transition } from "@headlessui/react"; +// plane editor +import { EditorReadOnlyRefApi } from "@plane/editor"; // ui import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui"; // components import { RichTextReadOnlyEditor } from "@/components/editor/rich-text-editor/rich-text-read-only-editor"; // services import { AIService } from "@/services/ai.service"; +const aiService = new AIService(); type Props = { isOpen: boolean; @@ -22,6 +25,7 @@ type Props = { prompt?: string; button: JSX.Element; className?: string; + workspaceId: string; workspaceSlug: string; projectId: string; }; @@ -31,8 +35,6 @@ type FormData = { task: string; }; -const aiService = new AIService(); - export const GptAssistantPopover: React.FC = (props) => { const { isOpen, @@ -43,6 +45,7 @@ export const GptAssistantPopover: React.FC = (props) => { prompt, button, className = "", + workspaceId, workspaceSlug, projectId, } = props; @@ -51,7 +54,8 @@ export const GptAssistantPopover: React.FC = (props) => { const [invalidResponse, setInvalidResponse] = useState(false); const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); - const editorRef = useRef(null); + // refs + const editorRef = useRef(null); const responseRef = useRef(null); // popper const { styles, attributes } = usePopper(referenceElement, popperElement, { @@ -218,6 +222,7 @@ export const GptAssistantPopover: React.FC = (props) => { initialValue={prompt} containerClassName="-m-3" ref={editorRef} + workspaceId={workspaceId} workspaceSlug={workspaceSlug} projectId={projectId} /> @@ -230,6 +235,7 @@ export const GptAssistantPopover: React.FC = (props) => { id="ai-assistant-response" initialValue={`

${response}

`} ref={responseRef} + workspaceId={workspaceId} workspaceSlug={workspaceSlug} projectId={projectId} /> diff --git a/web/core/components/editor/lite-text-editor/lite-text-editor.tsx b/web/core/components/editor/lite-text-editor/lite-text-editor.tsx index afaff3d7ea6..3a8651a2a11 100644 --- a/web/core/components/editor/lite-text-editor/lite-text-editor.tsx +++ b/web/core/components/editor/lite-text-editor/lite-text-editor.tsx @@ -2,18 +2,16 @@ import React, { useState } from "react"; // plane constants import { EIssueCommentAccessSpecifier } from "@plane/constants"; // plane editor -import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor"; +import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef, TFileHandler } from "@plane/editor"; // components import { EditorMentionsRoot, IssueCommentToolbar } from "@/components/editor"; // helpers import { cn } from "@/helpers/common.helper"; -import { getEditorFileHandlers } from "@/helpers/editor.helper"; import { isCommentEmpty } from "@/helpers/string.helper"; // hooks -import { useEditorMention } from "@/hooks/use-editor-mention"; +import { useEditorConfig, useEditorMention } from "@/hooks/editor"; // plane web hooks import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging"; -import { useFileSize } from "@/plane-web/hooks/use-file-size"; // plane web services import { WorkspaceService } from "@/plane-web/services"; const workspaceService = new WorkspaceService(); @@ -29,7 +27,7 @@ interface LiteTextEditorWrapperProps showSubmitButton?: boolean; isSubmitting?: boolean; showToolbarInitially?: boolean; - uploadFile: (file: File) => Promise; + uploadFile: TFileHandler["upload"]; issue_id?: string; } @@ -63,8 +61,8 @@ export const LiteTextEditor = React.forwardRef(ref: React.ForwardedRef): ref is React.MutableRefObject { return !!ref && typeof ref === "object" && "current" in ref; } @@ -82,7 +80,6 @@ export const LiteTextEditor = React.forwardRef & { + workspaceId: string; workspaceSlug: string; projectId: string; }; export const LiteTextReadOnlyEditor = React.forwardRef( - ({ workspaceSlug, projectId, ...props }, ref) => { + ({ workspaceId, workspaceSlug, projectId, ...props }, ref) => { // editor flaggings const { liteTextEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString()); + // editor config + const { getReadOnlyEditorFileHandlers } = useEditorConfig(); return ( { @@ -20,7 +18,7 @@ interface RichTextEditorWrapperProps workspaceSlug: string; workspaceId: string; projectId?: string; - uploadFile: (file: File) => Promise; + uploadFile: TFileHandler["upload"]; } export const RichTextEditor = forwardRef((props, ref) => { @@ -32,15 +30,14 @@ export const RichTextEditor = forwardRef await searchMentionCallback(payload), }); - // file size - const { maxFileSize } = useFileSize(); + // editor config + const { getEditorFileHandlers } = useEditorConfig(); return ( & { + workspaceId: string; workspaceSlug: string; projectId?: string; }; export const RichTextReadOnlyEditor = React.forwardRef( - ({ workspaceSlug, projectId, ...props }, ref) => { + ({ workspaceId, workspaceSlug, projectId, ...props }, ref) => { // editor flaggings const { richTextEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString()); + // editor config + const { getReadOnlyEditorFileHandlers } = useEditorConfig(); return ( Promise; + uploadFile: TFileHandler["upload"]; parentClassName?: string; handleColorChange: (data: Partial) => Promise; handleDelete: () => void; @@ -50,8 +49,8 @@ export const StickyEditor = React.forwardRef(ref: React.ForwardedRef): ref is React.MutableRefObject { return !!ref && typeof ref === "object" && "current" in ref; } @@ -68,7 +67,6 @@ export const StickyEditor = React.forwardRef = observer((props onEnterKeyPress, onAssetUpload, } = props; + // store hooks + const { uploadEditorAsset } = useEditorAsset(); // hooks const { loader } = useProjectInbox(); const { isMobile } = usePlatformOS(); @@ -84,17 +83,18 @@ export const InboxIssueDescription: FC = observer((props containerClassName={containerClassName} onEnterKeyPress={onEnterKeyPress} tabIndex={getIndex("description_html")} - uploadFile={async (file) => { + uploadFile={async (blockId, file) => { try { - const { asset_id } = await fileService.uploadProjectAsset( - workspaceSlug, - projectId, - { + const { asset_id } = await uploadEditorAsset({ + blockId, + data: { entity_identifier: data.id ?? "", entity_type: EFileAssetType.ISSUE_DESCRIPTION, }, - file - ); + file, + projectId, + workspaceSlug, + }); onAssetUpload?.(asset_id); return asset_id; } catch (error) { diff --git a/web/core/components/issues/description-input.tsx b/web/core/components/issues/description-input.tsx index e4955dd1a6f..18cdb2e5f52 100644 --- a/web/core/components/issues/description-input.tsx +++ b/web/core/components/issues/description-input.tsx @@ -15,13 +15,10 @@ import { TIssueOperations } from "@/components/issues/issue-detail"; // helpers import { getDescriptionPlaceholder } from "@/helpers/issue.helper"; // hooks -import { useWorkspace } from "@/hooks/store"; +import { useEditorAsset, useWorkspace } from "@/hooks/store"; // plane web services import { WorkspaceService } from "@/plane-web/services"; -// services -import { FileService } from "@/services/file.service"; const workspaceService = new WorkspaceService(); -const fileService = new FileService(); export type IssueDescriptionInputProps = { containerClassName?: string; @@ -49,18 +46,20 @@ export const IssueDescriptionInput: FC = observer((p setIsSubmitting, placeholder, } = props; - + // states + const [localIssueDescription, setLocalIssueDescription] = useState({ + id: issueId, + description_html: initialValue, + }); + // store hooks + const { uploadEditorAsset } = useEditorAsset(); + // form info const { handleSubmit, reset, control } = useForm({ defaultValues: { description_html: initialValue, }, }); - const [localIssueDescription, setLocalIssueDescription] = useState({ - id: issueId, - description_html: initialValue, - }); - const handleDescriptionFormSubmit = useCallback( async (formData: Partial) => { await issueOperations.update(workspaceSlug, projectId, issueId, { @@ -129,17 +128,18 @@ export const IssueDescriptionInput: FC = observer((p }) } containerClassName={containerClassName} - uploadFile={async (file) => { + uploadFile={async (blockId, file) => { try { - const { asset_id } = await fileService.uploadProjectAsset( - workspaceSlug, - projectId, - { + const { asset_id } = await uploadEditorAsset({ + blockId, + data: { entity_identifier: issueId, entity_type: EFileAssetType.ISSUE_DESCRIPTION, }, - file - ); + file, + projectId, + workspaceSlug, + }); return asset_id; } catch (error) { console.log("Error in uploading issue asset:", error); @@ -152,6 +152,7 @@ export const IssueDescriptionInput: FC = observer((p id={issueId} initialValue={localIssueDescription.description_html ?? ""} containerClassName={containerClassName} + workspaceId={workspaceId} workspaceSlug={workspaceSlug} projectId={projectId} /> diff --git a/web/core/components/issues/issue-detail/issue-activity/comments/comment-card.tsx b/web/core/components/issues/issue-detail/issue-activity/comments/comment-card.tsx index baae682881a..4d5b960c8fe 100644 --- a/web/core/components/issues/issue-detail/issue-activity/comments/comment-card.tsx +++ b/web/core/components/issues/issue-detail/issue-activity/comments/comment-card.tsx @@ -159,8 +159,8 @@ export const IssueCommentCard: FC = observer((props) => { } }} showSubmitButton={false} - uploadFile={async (file) => { - const { asset_id } = await activityOperations.uploadCommentAsset(file, comment.id); + uploadFile={async (blockId, file) => { + const { asset_id } = await activityOperations.uploadCommentAsset(blockId, file, comment.id); return asset_id; }} /> @@ -201,6 +201,7 @@ export const IssueCommentCard: FC = observer((props) => { ref={showEditorRef} id={comment.id} initialValue={comment.comment_html ?? ""} + workspaceId={workspaceId} workspaceSlug={workspaceSlug} projectId={projectId} /> diff --git a/web/core/components/issues/issue-detail/issue-activity/comments/comment-create.tsx b/web/core/components/issues/issue-detail/issue-activity/comments/comment-create.tsx index 5727714f29b..6abd2d6e4e9 100644 --- a/web/core/components/issues/issue-detail/issue-activity/comments/comment-create.tsx +++ b/web/core/components/issues/issue-detail/issue-activity/comments/comment-create.tsx @@ -110,8 +110,8 @@ export const IssueCommentCreate: FC = (props) => { handleAccessChange={onAccessChange} showAccessSpecifier={showAccessSpecifier} isSubmitting={isSubmitting} - uploadFile={async (file) => { - const { asset_id } = await activityOperations.uploadCommentAsset(file); + uploadFile={async (blockId, file) => { + const { asset_id } = await activityOperations.uploadCommentAsset(blockId, file); setUploadedAssetIds((prev) => [...prev, asset_id]); return asset_id; }} diff --git a/web/core/components/issues/issue-detail/issue-activity/root.tsx b/web/core/components/issues/issue-detail/issue-activity/root.tsx index 90bc93f1c0e..c50b6438afe 100644 --- a/web/core/components/issues/issue-detail/issue-activity/root.tsx +++ b/web/core/components/issues/issue-detail/issue-activity/root.tsx @@ -13,15 +13,12 @@ import { IssueCommentCreate } from "@/components/issues"; import { ActivitySortRoot, IssueActivityCommentRoot } from "@/components/issues/issue-detail"; // constants // hooks -import { useIssueDetail, useProject, useUser, useUserPermissions } from "@/hooks/store"; +import { useEditorAsset, useIssueDetail, useProject, useUser, useUserPermissions } from "@/hooks/store"; // plane web components import { ActivityFilterRoot, IssueActivityWorklogCreateButton } from "@/plane-web/components/issues/worklog"; // plane web constants import { TActivityFilters, defaultActivityFilters } from "@/plane-web/constants/issues"; import { EUserPermissions } from "@/plane-web/constants/user-permissions"; -// services -import { FileService } from "@/services/file.service"; -const fileService = new FileService(); type TIssueActivity = { workspaceSlug: string; @@ -35,7 +32,7 @@ export type TActivityOperations = { createComment: (data: Partial) => Promise; updateComment: (commentId: string, data: Partial) => Promise; removeComment: (commentId: string) => Promise; - uploadCommentAsset: (file: File, commentId?: string) => Promise; + uploadCommentAsset: (blockId: string, file: File, commentId?: string) => Promise; }; export const IssueActivity: FC = observer((props) => { @@ -46,6 +43,7 @@ export const IssueActivity: FC = observer((props) => { defaultActivityFilters ); const { setValue: setSortOrder, storedValue: sortOrder } = useLocalStorage("activity_sort_order", E_SORT_ORDER.ASC); + // store hooks const { issue: { getIssueById }, createComment, @@ -55,7 +53,8 @@ export const IssueActivity: FC = observer((props) => { const { projectPermissionsByWorkspaceSlugAndProjectId } = useUserPermissions(); const { getProjectById } = useProject(); const { data: currentUser } = useUser(); - //derived values + const { uploadEditorAsset } = useEditorAsset(); + // derived values const issue = issueId ? getIssueById(issueId) : undefined; const currentUserProjectRole = projectPermissionsByWorkspaceSlugAndProjectId(workspaceSlug, projectId); const isAdmin = (currentUserProjectRole ?? EUserPermissions.GUEST) === EUserPermissions.ADMIN; @@ -92,7 +91,7 @@ export const IssueActivity: FC = observer((props) => { message: "Comment created successfully.", }); return comment; - } catch (error) { + } catch { setToast({ title: "Error!", type: TOAST_TYPE.ERROR, @@ -109,7 +108,7 @@ export const IssueActivity: FC = observer((props) => { type: TOAST_TYPE.SUCCESS, message: "Comment updated successfully.", }); - } catch (error) { + } catch { setToast({ title: "Error!", type: TOAST_TYPE.ERROR, @@ -126,7 +125,7 @@ export const IssueActivity: FC = observer((props) => { type: TOAST_TYPE.SUCCESS, message: "Comment removed successfully.", }); - } catch (error) { + } catch { setToast({ title: "Error!", type: TOAST_TYPE.ERROR, @@ -134,18 +133,19 @@ export const IssueActivity: FC = observer((props) => { }); } }, - uploadCommentAsset: async (file, commentId) => { + uploadCommentAsset: async (blockId, file, commentId) => { try { if (!workspaceSlug || !projectId) throw new Error("Missing fields"); - const res = await fileService.uploadProjectAsset( - workspaceSlug, - projectId, - { + const res = await uploadEditorAsset({ + blockId, + data: { entity_identifier: commentId ?? "", entity_type: EFileAssetType.COMMENT_DESCRIPTION, }, - file - ); + file, + projectId, + workspaceSlug, + }); return res; } catch (error) { console.log("Error in uploading comment asset:", error); @@ -153,7 +153,7 @@ export const IssueActivity: FC = observer((props) => { } }, }), - [workspaceSlug, projectId, issueId, createComment, updateComment, removeComment] + [workspaceSlug, projectId, issueId, createComment, updateComment, uploadEditorAsset, removeComment] ); const project = getProjectById(projectId); diff --git a/web/core/components/issues/issue-modal/components/description-editor.tsx b/web/core/components/issues/issue-modal/components/description-editor.tsx index 8fc7b115b62..715c68351c5 100644 --- a/web/core/components/issues/issue-modal/components/description-editor.tsx +++ b/web/core/components/issues/issue-modal/components/description-editor.tsx @@ -20,14 +20,15 @@ import { ETabIndices } from "@/constants/tab-indices"; import { getDescriptionPlaceholder } from "@/helpers/issue.helper"; import { getTabIndex } from "@/helpers/tab-indices.helper"; // hooks -import { useInstance, useWorkspace } from "@/hooks/store"; +import { useEditorAsset, useInstance, useWorkspace } from "@/hooks/store"; import useKeypress from "@/hooks/use-keypress"; import { usePlatformOS } from "@/hooks/use-platform-os"; // plane web services import { WorkspaceService } from "@/plane-web/services"; // services import { AIService } from "@/services/ai.service"; -import { FileService } from "@/services/file.service"; +const workspaceService = new WorkspaceService(); +const aiService = new AIService(); type TIssueDescriptionEditorProps = { control: Control; @@ -48,11 +49,6 @@ type TIssueDescriptionEditorProps = { onClose: () => void; }; -// services -const workspaceService = new WorkspaceService(); -const aiService = new AIService(); -const fileService = new FileService(); - export const IssueDescriptionEditor: React.FC = observer((props) => { const { control, @@ -76,8 +72,10 @@ export const IssueDescriptionEditor: React.FC = ob const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); // store hooks const { getWorkspaceBySlug } = useWorkspace(); - const workspaceId = getWorkspaceBySlug(workspaceSlug?.toString())?.id as string; + const workspaceId = getWorkspaceBySlug(workspaceSlug?.toString())?.id ?? ""; const { config } = useInstance(); + const { uploadEditorAsset } = useEditorAsset(); + // platform const { isMobile } = usePlatformOS(); const { getIndex } = getTabIndex(ETabIndices.ISSUE_FORM, isMobile); @@ -198,19 +196,20 @@ export const IssueDescriptionEditor: React.FC = ob }) } containerClassName="pt-3 min-h-[120px]" - uploadFile={async (file) => { + uploadFile={async (blockId, file) => { try { - const { asset_id } = await fileService.uploadProjectAsset( - workspaceSlug, - projectId, - { + const { asset_id } = await uploadEditorAsset({ + blockId, + data: { entity_identifier: issueId ?? "", entity_type: isDraft ? EFileAssetType.DRAFT_ISSUE_DESCRIPTION : EFileAssetType.ISSUE_DESCRIPTION, }, - file - ); + file, + projectId, + workspaceSlug, + }); onAssetUpload(asset_id); return asset_id; } catch (error) { @@ -264,6 +263,7 @@ export const IssueDescriptionEditor: React.FC = ob AI } + workspaceId={workspaceId} workspaceSlug={workspaceSlug} projectId={projectId} /> diff --git a/web/core/components/pages/editor/editor-body.tsx b/web/core/components/pages/editor/editor-body.tsx index 161d63c47cc..c2c57238f9b 100644 --- a/web/core/components/pages/editor/editor-body.tsx +++ b/web/core/components/pages/editor/editor-body.tsx @@ -21,8 +21,8 @@ import { PageContentBrowser, PageContentLoader, PageEditorTitle } from "@/compon import { cn, LIVE_BASE_PATH, LIVE_BASE_URL } from "@/helpers/common.helper"; import { generateRandomColor } from "@/helpers/string.helper"; // hooks -import { useUser } from "@/hooks/store"; -import { useEditorMention } from "@/hooks/use-editor-mention"; +import { useEditorMention } from "@/hooks/editor"; +import { useUser, useWorkspace } from "@/hooks/store"; import { usePageFilters } from "@/hooks/use-page-filters"; // plane web components import { EditorAIMenu } from "@/plane-web/components/pages"; @@ -66,8 +66,10 @@ export const PageEditorBody: React.FC = observer((props) => { } = props; // store hooks const { data: currentUser } = useUser(); + const { getWorkspaceBySlug } = useWorkspace(); // derived values const { id: pageId, name: pageTitle, isContentEditable, updateTitle } = page; + const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id ?? ""; // issue-embed const { issueEmbedProps } = useIssueEmbed({ fetchEmbedSuggestions: handlers.fetchEntity, @@ -96,10 +98,11 @@ export const PageEditorBody: React.FC = observer((props) => { editorRef={editorRef} isOpen={isOpen} onClose={onClose} + workspaceId={workspaceId} workspaceSlug={workspaceSlug?.toString() ?? ""} /> ), - [editorRef, workspaceSlug] + [editorRef, workspaceId, workspaceSlug] ); const handleServerConnect = useCallback(() => { diff --git a/web/core/components/pages/version/editor.tsx b/web/core/components/pages/version/editor.tsx index 0beffa6f358..d201232904d 100644 --- a/web/core/components/pages/version/editor.tsx +++ b/web/core/components/pages/version/editor.tsx @@ -8,9 +8,9 @@ import { TPageVersion } from "@plane/types"; import { Loader } from "@plane/ui"; // components import { EditorMentionsRoot } from "@/components/editor"; -// helpers -import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper"; // hooks +import { useEditorConfig } from "@/hooks/editor"; +import { useWorkspace } from "@/hooks/store"; import { usePageFilters } from "@/hooks/use-page-filters"; // plane web hooks import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging"; @@ -27,8 +27,14 @@ export const PagesVersionEditor: React.FC = observer((props const { activeVersion, currentVersionDescription, isCurrentVersionActive, versionDetails } = props; // params const { workspaceSlug, projectId } = useParams(); + // store hooks + const { getWorkspaceBySlug } = useWorkspace(); + // derived values + const workspaceDetails = getWorkspaceBySlug(workspaceSlug?.toString() ?? ""); // editor flaggings const { documentEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString() ?? ""); + // editor config + const { getReadOnlyEditorFileHandlers } = useEditorConfig(); // issue-embed const { issueEmbedProps } = useIssueEmbed({ projectId: projectId?.toString() ?? "", @@ -97,6 +103,7 @@ export const PagesVersionEditor: React.FC = observer((props editorClassName="pl-10" fileHandler={getReadOnlyEditorFileHandlers({ projectId: projectId?.toString() ?? "", + workspaceId: workspaceDetails?.id ?? "", workspaceSlug: workspaceSlug?.toString() ?? "", })} mentionHandler={{ diff --git a/web/core/components/profile/activity/activity-list.tsx b/web/core/components/profile/activity/activity-list.tsx index bdb6c6f9356..671fd9dc27f 100644 --- a/web/core/components/profile/activity/activity-list.tsx +++ b/web/core/components/profile/activity/activity-list.tsx @@ -15,7 +15,7 @@ import { ActivitySettingsLoader } from "@/components/ui"; import { calculateTimeAgo } from "@/helpers/date-time.helper"; import { getFileURL } from "@/helpers/file.helper"; // hooks -import { useUser } from "@/hooks/store"; +import { useUser, useWorkspace } from "@/hooks/store"; type Props = { activity: IUserActivityResponse | undefined; @@ -27,6 +27,9 @@ export const ActivityList: React.FC = observer((props) => { const { workspaceSlug } = useParams(); // store hooks const { data: currentUser } = useUser(); + const { getWorkspaceBySlug } = useWorkspace(); + // derived values + const workspaceId = getWorkspaceBySlug(workspaceSlug?.toString() ?? "")?.id ?? ""; // TODO: refactor this component return ( @@ -79,6 +82,7 @@ export const ActivityList: React.FC = observer((props) => { : (activityItem.old_value?.toString() as string) } containerClassName="text-xs bg-custom-background-100" + workspaceId={workspaceId} workspaceSlug={workspaceSlug?.toString() ?? ""} projectId={activityItem.project} /> diff --git a/web/core/components/profile/activity/profile-activity-list.tsx b/web/core/components/profile/activity/profile-activity-list.tsx index 5046948c00d..4d10ce2b1e4 100644 --- a/web/core/components/profile/activity/profile-activity-list.tsx +++ b/web/core/components/profile/activity/profile-activity-list.tsx @@ -103,7 +103,8 @@ export const ProfileActivityListPage: React.FC = observer((props) => { activityItem?.new_value !== "" ? activityItem.new_value : activityItem.old_value } containerClassName="text-xs bg-custom-background-100" - workspaceSlug={activityItem?.workspace_detail?.slug.toString() ?? ""} + workspaceId={activityItem?.workspace_detail?.id?.toString() ?? ""} + workspaceSlug={activityItem?.workspace_detail?.slug?.toString() ?? ""} projectId={activityItem.project ?? ""} />
diff --git a/web/core/hooks/editor/index.ts b/web/core/hooks/editor/index.ts new file mode 100644 index 00000000000..532916cd480 --- /dev/null +++ b/web/core/hooks/editor/index.ts @@ -0,0 +1,2 @@ +export * from "./use-editor-config"; +export * from "./use-editor-mention"; diff --git a/web/core/hooks/editor/use-editor-config.ts b/web/core/hooks/editor/use-editor-config.ts new file mode 100644 index 00000000000..043dfa8fa20 --- /dev/null +++ b/web/core/hooks/editor/use-editor-config.ts @@ -0,0 +1,96 @@ +import { useCallback } from "react"; +// plane editor +import { TFileHandler, TReadOnlyFileHandler } from "@plane/editor"; +// helpers +import { getEditorAssetSrc } from "@/helpers/editor.helper"; +// hooks +import { useEditorAsset } from "@/hooks/store"; +// plane web hooks +import { useFileSize } from "@/plane-web/hooks/use-file-size"; +// services +import { FileService } from "@/services/file.service"; +const fileService = new FileService(); + +type TArgs = { + projectId?: string; + uploadFile: TFileHandler["upload"]; + workspaceId: string; + workspaceSlug: string; +}; + +export const useEditorConfig = () => { + // store hooks + const { getAssetUploadStatusByEditorBlockId } = useEditorAsset(); + // file size + const { maxFileSize } = useFileSize(); + + const getReadOnlyEditorFileHandlers = useCallback( + (args: Pick): TReadOnlyFileHandler => { + const { projectId, workspaceId, workspaceSlug } = args; + + return { + getAssetSrc: async (path) => { + if (!path) return ""; + if (path?.startsWith("http")) { + return path; + } else { + return ( + getEditorAssetSrc({ + assetId: path, + projectId, + workspaceSlug, + }) ?? "" + ); + } + }, + restore: async (src: string) => { + if (src?.startsWith("http")) { + await fileService.restoreOldEditorAsset(workspaceId, src); + } else { + await fileService.restoreNewAsset(workspaceSlug, src); + } + }, + }; + }, + [] + ); + + const getEditorFileHandlers = useCallback( + (args: TArgs): TFileHandler => { + const { projectId, uploadFile, workspaceId, workspaceSlug } = args; + + return { + ...getReadOnlyEditorFileHandlers({ + projectId, + workspaceId, + workspaceSlug, + }), + getAssetUploadStatus: (blockId) => getAssetUploadStatusByEditorBlockId(blockId)?.progress ?? 0, + upload: uploadFile, + delete: async (src: string) => { + if (src?.startsWith("http")) { + await fileService.deleteOldWorkspaceAsset(workspaceId, src); + } else { + await fileService.deleteNewAsset( + getEditorAssetSrc({ + assetId: src, + projectId, + workspaceSlug, + }) ?? "" + ); + } + }, + cancel: fileService.cancelUpload, + validation: { + maxFileSize, + }, + }; + }, + [getAssetUploadStatusByEditorBlockId, getReadOnlyEditorFileHandlers, maxFileSize] + ); + + return { + getEditorFileHandlers, + getReadOnlyEditorFileHandlers, + }; +}; diff --git a/web/core/hooks/use-editor-mention.tsx b/web/core/hooks/editor/use-editor-mention.tsx similarity index 100% rename from web/core/hooks/use-editor-mention.tsx rename to web/core/hooks/editor/use-editor-mention.tsx diff --git a/web/core/hooks/store/index.ts b/web/core/hooks/store/index.ts index 266efea32c5..fa9ff44cc9b 100644 --- a/web/core/hooks/store/index.ts +++ b/web/core/hooks/store/index.ts @@ -7,6 +7,7 @@ export * from "./use-command-palette"; export * from "./use-cycle"; export * from "./use-cycle-filter"; export * from "./use-dashboard"; +export * from "./use-editor-asset"; export * from "./use-event-tracker"; export * from "./use-global-view"; export * from "./use-inbox-issues"; diff --git a/web/core/hooks/store/use-editor-asset.ts b/web/core/hooks/store/use-editor-asset.ts new file mode 100644 index 00000000000..7c5af36960a --- /dev/null +++ b/web/core/hooks/store/use-editor-asset.ts @@ -0,0 +1,10 @@ +import { useContext } from "react"; +// store +import { StoreContext } from "@/lib/store-context"; +import { IEditorAssetStore } from "@/store/editor/asset.store"; + +export const useEditorAsset = (): IEditorAssetStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useEditorAsset must be used within StoreProvider"); + return context.editorAssetStore; +}; diff --git a/web/core/services/file.service.ts b/web/core/services/file.service.ts index d1f654ba717..dba3027c552 100644 --- a/web/core/services/file.service.ts +++ b/web/core/services/file.service.ts @@ -1,3 +1,4 @@ +import { AxiosRequestConfig } from "axios"; // plane types import { TFileEntityInfo, TFileSignedURLResponse } from "@plane/types"; // helpers @@ -64,7 +65,8 @@ export class FileService extends APIService { async uploadWorkspaceAsset( workspaceSlug: string, data: TFileEntityInfo, - file: File + file: File, + uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"] ): Promise { const fileMetaData = getFileMetaDataForUpload(file); return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/`, { @@ -74,7 +76,11 @@ export class FileService extends APIService { .then(async (response) => { const signedURLResponse: TFileSignedURLResponse = response?.data; const fileUploadPayload = generateFileUploadPayload(signedURLResponse, file); - await this.fileUploadService.uploadFile(signedURLResponse.upload_data.url, fileUploadPayload); + await this.fileUploadService.uploadFile( + signedURLResponse.upload_data.url, + fileUploadPayload, + uploadProgressHandler + ); await this.updateWorkspaceAssetUploadStatus(workspaceSlug.toString(), signedURLResponse.asset_id); return signedURLResponse; }) @@ -122,7 +128,8 @@ export class FileService extends APIService { workspaceSlug: string, projectId: string, data: TFileEntityInfo, - file: File + file: File, + uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"] ): Promise { const fileMetaData = getFileMetaDataForUpload(file); return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/`, { @@ -132,7 +139,11 @@ export class FileService extends APIService { .then(async (response) => { const signedURLResponse: TFileSignedURLResponse = response?.data; const fileUploadPayload = generateFileUploadPayload(signedURLResponse, file); - await this.fileUploadService.uploadFile(signedURLResponse.upload_data.url, fileUploadPayload); + await this.fileUploadService.uploadFile( + signedURLResponse.upload_data.url, + fileUploadPayload, + uploadProgressHandler + ); await this.updateProjectAssetUploadStatus(workspaceSlug, projectId, signedURLResponse.asset_id); return signedURLResponse; }) diff --git a/web/core/store/editor/asset.store.ts b/web/core/store/editor/asset.store.ts new file mode 100644 index 00000000000..a8fac12c64b --- /dev/null +++ b/web/core/store/editor/asset.store.ts @@ -0,0 +1,113 @@ +import debounce from "lodash/debounce"; +import set from "lodash/set"; +import { action, makeObservable, observable, runInAction } from "mobx"; +import { computedFn } from "mobx-utils"; +import { v4 as uuidv4 } from "uuid"; +// plane types +import { TFileEntityInfo, TFileSignedURLResponse } from "@plane/types"; +// services +import { FileService } from "@/services/file.service"; +import { TAttachmentUploadStatus } from "../issue/issue-details/attachment.store"; + +export interface IEditorAssetStore { + // observables + assetUploadStatus: Record; // assetId => TAttachmentUploadStatus + // helper methods + getAssetUploadStatusByEditorBlockId: (blockId: string) => TAttachmentUploadStatus | undefined; + // actions + uploadEditorAsset: ({ + workspaceSlug, + blockId, + data, + file, + projectId, + }: { + blockId: string; + data: TFileEntityInfo; + file: File; + projectId?: string; + workspaceSlug: string; + }) => Promise; +} + +export class EditorAssetStore implements IEditorAssetStore { + // observables + assetUploadStatus: Record = {}; + // services + fileService: FileService; + + constructor() { + makeObservable(this, { + // observables + assetUploadStatus: observable, + // actions + uploadEditorAsset: action, + }); + // services + this.fileService = new FileService(); + } + + // helper methods + getAssetUploadStatusByEditorBlockId: IEditorAssetStore["getAssetUploadStatusByEditorBlockId"] = computedFn( + (blockId) => { + if (!blockId) return undefined; + const assetUploadStatus = this.assetUploadStatus[blockId] ?? undefined; + return assetUploadStatus; + } + ); + + // actions + private debouncedUpdateProgress = debounce((blockId: string, progress: number) => { + runInAction(() => { + set(this.assetUploadStatus, [blockId, "progress"], progress); + }); + }, 16); + + uploadEditorAsset: IEditorAssetStore["uploadEditorAsset"] = async ({ + blockId, + data, + file, + projectId, + workspaceSlug, + }) => { + const tempId = uuidv4(); + try { + // update attachment upload status + runInAction(() => { + set(this.assetUploadStatus, [blockId], { + id: tempId, + name: file.name, + progress: 0, + size: file.size, + type: file.type, + }); + }); + if (projectId) { + const response = await this.fileService.uploadProjectAsset( + workspaceSlug, + projectId, + data, + file, + (progressEvent) => { + const progressPercentage = Math.round((progressEvent.progress ?? 0) * 100); + this.debouncedUpdateProgress(blockId, progressPercentage); + } + ); + return response; + } else { + const response = await this.fileService.uploadWorkspaceAsset(workspaceSlug, data, file, (progressEvent) => { + const progressPercentage = Math.round((progressEvent.progress ?? 0) * 100); + this.debouncedUpdateProgress(blockId, progressPercentage); + }); + return response; + } + } catch (error) { + console.error("Error in uploading page asset:", error); + throw error; + } finally { + runInAction(() => { + delete this.assetUploadStatus[blockId]; + }); + } + }; +} diff --git a/web/core/store/root.store.ts b/web/core/store/root.store.ts index 03f1acdf497..5a10df45be0 100644 --- a/web/core/store/root.store.ts +++ b/web/core/store/root.store.ts @@ -1,5 +1,4 @@ import { enableStaticRendering } from "mobx-react"; -import { EIssueServiceType } from "@plane/constants"; // plane web store import { CommandPaletteStore, ICommandPaletteStore } from "@/plane-web/store/command-palette.store"; import { RootStore } from "@/plane-web/store/root.store"; @@ -8,6 +7,7 @@ import { IStateStore, StateStore } from "@/plane-web/store/state.store"; import { CycleStore, ICycleStore } from "./cycle.store"; import { CycleFilterStore, ICycleFilterStore } from "./cycle_filter.store"; import { DashboardStore, IDashboardStore } from "./dashboard.store"; +import { EditorAssetStore, IEditorAssetStore } from "./editor/asset.store"; import { IProjectEstimateStore, ProjectEstimateStore } from "./estimates/project-estimate.store"; import { EventTrackerStore, IEventTrackerStore } from "./event-tracker.store"; import { FavoriteStore, IFavoriteStore } from "./favorite.store"; @@ -61,6 +61,7 @@ export class CoreRootStore { favorite: IFavoriteStore; transient: ITransientStore; stickyStore: IStickyStore; + editorAssetStore: IEditorAssetStore; constructor() { this.router = new RouterStore(); @@ -90,6 +91,7 @@ export class CoreRootStore { this.favorite = new FavoriteStore(this); this.transient = new TransientStore(); this.stickyStore = new StickyStore(); + this.editorAssetStore = new EditorAssetStore(); } resetOnSignOut() { @@ -122,5 +124,6 @@ export class CoreRootStore { this.favorite = new FavoriteStore(this); this.transient = new TransientStore(); this.stickyStore = new StickyStore(); + this.editorAssetStore = new EditorAssetStore(); } } diff --git a/web/helpers/editor.helper.ts b/web/helpers/editor.helper.ts index a3b05041d23..90a431190f5 100644 --- a/web/helpers/editor.helper.ts +++ b/web/helpers/editor.helper.ts @@ -1,10 +1,5 @@ -// plane editor -import { TFileHandler } from "@plane/editor"; // helpers import { getFileURL } from "@/helpers/file.helper"; -// services -import { FileService } from "@/services/file.service"; -const fileService = new FileService(); type TEditorSrcArgs = { assetId: string; @@ -27,90 +22,6 @@ export const getEditorAssetSrc = (args: TEditorSrcArgs): string | undefined => { return url; }; -type TArgs = { - maxFileSize: number; - projectId?: string; - uploadFile: (file: File) => Promise; - workspaceId: string; - workspaceSlug: string; -}; - -/** - * @description this function returns the file handler required by the editors - * @param {TArgs} args - */ -export const getEditorFileHandlers = (args: TArgs): TFileHandler => { - const { maxFileSize, projectId, uploadFile, workspaceId, workspaceSlug } = args; - - return { - getAssetSrc: async (path) => { - if (!path) return ""; - if (path?.startsWith("http")) { - return path; - } else { - return ( - getEditorAssetSrc({ - assetId: path, - projectId, - workspaceSlug, - }) ?? "" - ); - } - }, - upload: uploadFile, - delete: async (src: string) => { - if (src?.startsWith("http")) { - await fileService.deleteOldWorkspaceAsset(workspaceId, src); - } else { - await fileService.deleteNewAsset( - getEditorAssetSrc({ - assetId: src, - projectId, - workspaceSlug, - }) ?? "" - ); - } - }, - restore: async (src: string) => { - if (src?.startsWith("http")) { - await fileService.restoreOldEditorAsset(workspaceId, src); - } else { - await fileService.restoreNewAsset(workspaceSlug, src); - } - }, - cancel: fileService.cancelUpload, - validation: { - maxFileSize, - }, - }; -}; - -/** - * @description this function returns the file handler required by the read-only editors - */ -export const getReadOnlyEditorFileHandlers = ( - args: Pick -): { getAssetSrc: TFileHandler["getAssetSrc"] } => { - const { projectId, workspaceSlug } = args; - - return { - getAssetSrc: async (path) => { - if (!path) return ""; - if (path?.startsWith("http")) { - return path; - } else { - return ( - getEditorAssetSrc({ - assetId: path, - projectId, - workspaceSlug, - }) ?? "" - ); - } - }, - }; -}; - export const getTextContent = (jsx: JSX.Element | React.ReactNode | null | undefined): string => { if (!jsx) return ""; From f28b6f210c8109fee8d9b5679d73167c71a2dc48 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Wed, 22 Jan 2025 17:21:29 +0530 Subject: [PATCH 2/7] refactor: asset store --- .../editor/src/core/extensions/extensions.tsx | 4 ++-- web/core/store/editor/asset.store.ts | 18 +++++++----------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/editor/src/core/extensions/extensions.tsx b/packages/editor/src/core/extensions/extensions.tsx index 0b772baf9db..14031155afe 100644 --- a/packages/editor/src/core/extensions/extensions.tsx +++ b/packages/editor/src/core/extensions/extensions.tsx @@ -32,10 +32,10 @@ import { } from "@/extensions"; // helpers import { isValidHttpUrl } from "@/helpers/common"; -// types -import { TExtensions, TFileHandler, TMentionHandler } from "@/types"; // plane editor extensions import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions"; +// types +import { TExtensions, TFileHandler, TMentionHandler } from "@/types"; type TArguments = { disabledExtensions: TExtensions[]; diff --git a/web/core/store/editor/asset.store.ts b/web/core/store/editor/asset.store.ts index a8fac12c64b..80ed5e1b0e5 100644 --- a/web/core/store/editor/asset.store.ts +++ b/web/core/store/editor/asset.store.ts @@ -16,11 +16,11 @@ export interface IEditorAssetStore { getAssetUploadStatusByEditorBlockId: (blockId: string) => TAttachmentUploadStatus | undefined; // actions uploadEditorAsset: ({ - workspaceSlug, blockId, data, file, projectId, + workspaceSlug, }: { blockId: string; data: TFileEntityInfo; @@ -50,9 +50,9 @@ export class EditorAssetStore implements IEditorAssetStore { // helper methods getAssetUploadStatusByEditorBlockId: IEditorAssetStore["getAssetUploadStatusByEditorBlockId"] = computedFn( (blockId) => { - if (!blockId) return undefined; - const assetUploadStatus = this.assetUploadStatus[blockId] ?? undefined; - return assetUploadStatus; + const blockDetails = this.assetUploadStatus[blockId]; + if (!blockDetails) return undefined; + return blockDetails; } ); @@ -63,14 +63,10 @@ export class EditorAssetStore implements IEditorAssetStore { }); }, 16); - uploadEditorAsset: IEditorAssetStore["uploadEditorAsset"] = async ({ - blockId, - data, - file, - projectId, - workspaceSlug, - }) => { + uploadEditorAsset: IEditorAssetStore["uploadEditorAsset"] = async (args) => { + const { blockId, data, file, projectId, workspaceSlug } = args; const tempId = uuidv4(); + try { // update attachment upload status runInAction(() => { From e51ddd63be7ef0efba59842abb4720633be39121 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Wed, 22 Jan 2025 17:36:10 +0530 Subject: [PATCH 3/7] refactor: space app file handlers --- .../components/editor/lite-text-editor.tsx | 4 +- .../editor/lite-text-read-only-editor.tsx | 4 +- .../components/editor/rich-text-editor.tsx | 12 +++-- .../editor/rich-text-read-only-editor.tsx | 4 +- .../peek-overview/comment/add-comment.tsx | 2 +- .../comment/comment-detail-card.tsx | 3 +- .../issues/peek-overview/issue-details.tsx | 7 +-- space/helpers/editor.helper.ts | 51 +++++++++---------- 8 files changed, 45 insertions(+), 42 deletions(-) diff --git a/space/core/components/editor/lite-text-editor.tsx b/space/core/components/editor/lite-text-editor.tsx index ac0a0633a45..9f2cda4ad51 100644 --- a/space/core/components/editor/lite-text-editor.tsx +++ b/space/core/components/editor/lite-text-editor.tsx @@ -1,6 +1,6 @@ import React from "react"; // editor -import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor"; +import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef, TFileHandler } from "@plane/editor"; // components import { EditorMentionsRoot, IssueCommentToolbar } from "@/components/editor"; // helpers @@ -14,7 +14,7 @@ interface LiteTextEditorWrapperProps workspaceId: string; isSubmitting?: boolean; showSubmitButton?: boolean; - uploadFile: (file: File) => Promise; + uploadFile: TFileHandler["upload"]; } export const LiteTextEditor = React.forwardRef((props, ref) => { diff --git a/space/core/components/editor/lite-text-read-only-editor.tsx b/space/core/components/editor/lite-text-read-only-editor.tsx index 5f936baec5a..f9889f2ab6d 100644 --- a/space/core/components/editor/lite-text-read-only-editor.tsx +++ b/space/core/components/editor/lite-text-read-only-editor.tsx @@ -12,15 +12,17 @@ type LiteTextReadOnlyEditorWrapperProps = Omit< "disabledExtensions" | "fileHandler" | "mentionHandler" > & { anchor: string; + workspaceId: string; }; export const LiteTextReadOnlyEditor = React.forwardRef( - ({ anchor, ...props }, ref) => ( + ({ anchor, workspaceId, ...props }, ref) => ( , diff --git a/space/core/components/editor/rich-text-editor.tsx b/space/core/components/editor/rich-text-editor.tsx index 96f4900548c..c9e795d7bdc 100644 --- a/space/core/components/editor/rich-text-editor.tsx +++ b/space/core/components/editor/rich-text-editor.tsx @@ -1,6 +1,6 @@ import React, { forwardRef } from "react"; // editor -import { EditorRefApi, IRichTextEditor, RichTextEditorWithRef } from "@plane/editor"; +import { EditorRefApi, IRichTextEditor, RichTextEditorWithRef, TFileHandler } from "@plane/editor"; // components import { EditorMentionsRoot } from "@/components/editor"; // helpers @@ -8,11 +8,13 @@ import { getEditorFileHandlers } from "@/helpers/editor.helper"; interface RichTextEditorWrapperProps extends Omit { - uploadFile: (file: File) => Promise; + anchor: string; + uploadFile: TFileHandler["upload"]; + workspaceId: string; } export const RichTextEditor = forwardRef((props, ref) => { - const { containerClassName, uploadFile, ...rest } = props; + const { anchor, containerClassName, uploadFile, workspaceId, ...rest } = props; return ( & { anchor: string; + workspaceId: string; }; export const RichTextReadOnlyEditor = React.forwardRef( - ({ anchor, ...props }, ref) => ( + ({ anchor, workspaceId, ...props }, ref) => ( , diff --git a/space/core/components/issues/peek-overview/comment/add-comment.tsx b/space/core/components/issues/peek-overview/comment/add-comment.tsx index 9712b64e690..d746d776622 100644 --- a/space/core/components/issues/peek-overview/comment/add-comment.tsx +++ b/space/core/components/issues/peek-overview/comment/add-comment.tsx @@ -90,7 +90,7 @@ export const AddComment: React.FC = observer((props) => { onChange={(comment_json, comment_html) => onChange(comment_html)} isSubmitting={isSubmitting} placeholder="Add comment..." - uploadFile={async (file) => { + uploadFile={async (blockId, file) => { const { asset_id } = await uploadCommentAsset(file, anchor); setUploadAssetIds((prev) => [...prev, asset_id]); return asset_id; diff --git a/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx b/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx index e229ac21e81..70fcedd0ac1 100644 --- a/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx +++ b/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx @@ -112,7 +112,7 @@ export const CommentCard: React.FC = observer((props) => { onChange={(comment_json, comment_html) => onChange(comment_html)} isSubmitting={isSubmitting} showSubmitButton={false} - uploadFile={async (file) => { + uploadFile={async (blockId, file) => { const { asset_id } = await uploadCommentAsset(file, anchor, comment.id); return asset_id; }} @@ -140,6 +140,7 @@ export const CommentCard: React.FC = observer((props) => {
= observer((props) => { const { anchor, issueDetails } = props; - - const { project_details } = usePublish(anchor); - + // store hooks + const { project_details, workspace: workspaceID } = usePublish(anchor); + // derived values const description = issueDetails.description_html; return ( @@ -35,6 +35,7 @@ export const PeekOverviewIssueDetails: React.FC = observer((props) => { ? "

" : description } + workspaceId={workspaceID?.toString() ?? ""} /> )} diff --git a/space/helpers/editor.helper.ts b/space/helpers/editor.helper.ts index 52d83ccc9c7..0907b421e09 100644 --- a/space/helpers/editor.helper.ts +++ b/space/helpers/editor.helper.ts @@ -1,6 +1,6 @@ // plane internal import { MAX_FILE_SIZE } from "@plane/constants"; -import { TFileHandler } from "@plane/editor"; +import { TFileHandler, TReadOnlyFileHandler } from "@plane/editor"; import { SitesFileService } from "@plane/services"; // helpers import { getFileURL } from "@/helpers/file.helper"; @@ -18,16 +18,15 @@ export const getEditorAssetSrc = (anchor: string, assetId: string): string | und type TArgs = { anchor: string; - uploadFile: (file: File) => Promise; + uploadFile: TFileHandler["upload"]; workspaceId: string; }; /** - * @description this function returns the file handler required by the editors - * @param {TArgs} args + * @description this function returns the file handler required by the read-only editors */ -export const getEditorFileHandlers = (args: TArgs): TFileHandler => { - const { anchor, uploadFile, workspaceId } = args; +export const getReadOnlyEditorFileHandlers = (args: Pick): TReadOnlyFileHandler => { + const { anchor, workspaceId } = args; return { getAssetSrc: async (path) => { @@ -38,14 +37,6 @@ export const getEditorFileHandlers = (args: TArgs): TFileHandler => { return getEditorAssetSrc(anchor, path) ?? ""; } }, - upload: uploadFile, - delete: async (src: string) => { - if (src?.startsWith("http")) { - await sitesFileService.deleteOldEditorAsset(workspaceId, src); - } else { - await sitesFileService.deleteNewAsset(getEditorAssetSrc(anchor, src) ?? ""); - } - }, restore: async (src: string) => { if (src?.startsWith("http")) { await sitesFileService.restoreOldEditorAsset(workspaceId, src); @@ -53,29 +44,33 @@ export const getEditorFileHandlers = (args: TArgs): TFileHandler => { await sitesFileService.restoreNewAsset(anchor, src); } }, - cancel: sitesFileService.cancelUpload, - validation: { - maxFileSize: MAX_FILE_SIZE, - }, }; }; /** - * @description this function returns the file handler required by the read-only editors + * @description this function returns the file handler required by the editors + * @param {TArgs} args */ -export const getReadOnlyEditorFileHandlers = ( - args: Pick -): { getAssetSrc: TFileHandler["getAssetSrc"] } => { - const { anchor } = args; +export const getEditorFileHandlers = (args: TArgs): TFileHandler => { + const { anchor, uploadFile, workspaceId } = args; return { - getAssetSrc: async (path) => { - if (!path) return ""; - if (path?.startsWith("http")) { - return path; + ...getReadOnlyEditorFileHandlers({ + anchor, + workspaceId, + }), + getAssetUploadStatus: () => 0, + upload: uploadFile, + delete: async (src: string) => { + if (src?.startsWith("http")) { + await sitesFileService.deleteOldEditorAsset(workspaceId, src); } else { - return getEditorAssetSrc(anchor, path) ?? ""; + await sitesFileService.deleteNewAsset(getEditorAssetSrc(anchor, src) ?? ""); } }, + cancel: sitesFileService.cancelUpload, + validation: { + maxFileSize: MAX_FILE_SIZE, + }, }; }; From 873981f3a6be64f38b091e8334ee2c346b38f0f9 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Wed, 22 Jan 2025 22:07:17 +0530 Subject: [PATCH 4/7] fix: separate webhook connection params --- .../custom-image/components/image-block.tsx | 6 +++- .../custom-image/components/upload-status.tsx | 22 ++++++++++++++ .../extensions/custom-image/custom-image.ts | 7 +++++ .../custom-image/read-only-custom-image.ts | 1 + packages/editor/src/core/hooks/use-editor.ts | 7 +++++ packages/editor/src/core/types/config.ts | 2 +- .../pages/(detail)/[pageId]/page.tsx | 17 +++++++---- .../components/pages/editor/editor-body.tsx | 7 +++-- .../components/pages/editor/page-root.tsx | 6 ++-- web/core/hooks/editor/use-editor-config.ts | 6 ++-- web/core/store/editor/asset.store.ts | 30 +++++++++++++------ .../issue/issue-details/attachment.store.ts | 2 +- 12 files changed, 87 insertions(+), 26 deletions(-) create mode 100644 packages/editor/src/core/extensions/custom-image/components/upload-status.tsx diff --git a/packages/editor/src/core/extensions/custom-image/components/image-block.tsx b/packages/editor/src/core/extensions/custom-image/components/image-block.tsx index 89194aae0c3..4817d2d17f6 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-block.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-block.tsx @@ -1,9 +1,10 @@ -import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react"; import { NodeSelection } from "@tiptap/pm/state"; +import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react"; // plane utils import { cn } from "@plane/utils"; // extensions import { CustoBaseImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image"; +import { ImageUploadStatus } from "./upload-status"; const MIN_SIZE = 100; @@ -210,6 +211,8 @@ export const CustomImageBlock: React.FC = (props) => { // show the image loader if the remote image's src or preview image from filesystem is not set yet (while loading the image post upload) (or) // if the initial resize (from 35% width and "auto" height attrs to the actual size in px) is not complete const showImageLoader = !(resolvedImageSrc || imageFromFileSystem) || !initialResizeComplete || hasErroredOnFirstLoad; + // show the image upload status only when the resolvedImageSrc is not ready + const showUploadStatus = !resolvedImageSrc; // show the image utils only if the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem) const showImageUtils = resolvedImageSrc && initialResizeComplete; // show the image resizer only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem) @@ -270,6 +273,7 @@ export const CustomImageBlock: React.FC = (props) => { ...(size.aspectRatio && { aspectRatio: size.aspectRatio }), }} /> + {showUploadStatus && } {showImageUtils && ( = (props) => { + const { editor, nodeId } = props; + // subscribe to image upload status + const uploadStatus = useEditorState({ + editor, + selector: ({ editor }) => editor.storage.imageComponent.assetsUploadStatus[nodeId], + }); + + return ( +
+ {uploadStatus}% +
+ ); +}; diff --git a/packages/editor/src/core/extensions/custom-image/custom-image.ts b/packages/editor/src/core/extensions/custom-image/custom-image.ts index e8121ee5dfd..a4d38ceb59b 100644 --- a/packages/editor/src/core/extensions/custom-image/custom-image.ts +++ b/packages/editor/src/core/extensions/custom-image/custom-image.ts @@ -22,6 +22,7 @@ declare module "@tiptap/core" { imageComponent: { insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType; uploadImage: (blockId: string, file: File) => () => Promise | undefined; + updateAssetsUploadStatus: (updatedStatus: TFileHandler["assetsUploadStatus"]) => () => void; getImageSource?: (path: string) => () => Promise; restoreImage: (src: string) => () => Promise; }; @@ -32,6 +33,7 @@ export const getImageComponentImageFileMap = (editor: Editor) => (editor.storage.imageComponent as UploadImageExtensionStorage | undefined)?.fileMap; export interface UploadImageExtensionStorage { + assetsUploadStatus: TFileHandler["assetsUploadStatus"]; fileMap: Map; } @@ -39,6 +41,7 @@ export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) export const CustomImageExtension = (props: TFileHandler) => { const { + assetsUploadStatus, getAssetSrc, upload, delete: deleteImageFn, @@ -127,6 +130,7 @@ export const CustomImageExtension = (props: TFileHandler) => { markdown: { serialize() {}, }, + assetsUploadStatus, }; }, @@ -185,6 +189,9 @@ export const CustomImageExtension = (props: TFileHandler) => { const fileUrl = await upload(blockId, file); return fileUrl; }, + updateAssetsUploadStatus: (updatedStatus) => () => { + this.storage.assetsUploadStatus = updatedStatus; + }, getImageSource: (path) => async () => await getAssetSrc(path), restoreImage: (src) => async () => { await restoreImageFn(src); diff --git a/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts b/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts index 928d074d2bb..78237d67835 100644 --- a/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts +++ b/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts @@ -56,6 +56,7 @@ export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => { markdown: { serialize() {}, }, + assetsUploadStatus: {}, }; }, diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index af970b670a6..479842e3170 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -125,6 +125,13 @@ export const useEditor = (props: CustomEditorProps) => { } }, [editor, value, id]); + // update assets upload status + useEffect(() => { + if (!editor) return; + const assetsUploadStatus = fileHandler.assetsUploadStatus; + editor.commands.updateAssetsUploadStatus(assetsUploadStatus); + }, [editor, fileHandler.assetsUploadStatus]); + useImperativeHandle( forwardedRef, () => ({ diff --git a/packages/editor/src/core/types/config.ts b/packages/editor/src/core/types/config.ts index 0e5a547fa32..d4d8ca9010c 100644 --- a/packages/editor/src/core/types/config.ts +++ b/packages/editor/src/core/types/config.ts @@ -6,7 +6,7 @@ export type TReadOnlyFileHandler = { }; export type TFileHandler = TReadOnlyFileHandler & { - getAssetUploadStatus: (blockId: string) => number; + assetsUploadStatus: Record; // blockId => progress percentage cancel: () => void; delete: DeleteImage; upload: UploadImage; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx index c22b12ee2dd..4a87a9cbc4b 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx @@ -6,7 +6,7 @@ import Link from "next/link"; import { useParams } from "next/navigation"; import useSWR from "swr"; // plane types -import { TSearchEntityRequestPayload } from "@plane/types"; +import { TSearchEntityRequestPayload, TWebhookConnectionQueryParams } from "@plane/types"; import { EFileAssetType } from "@plane/types/src/enums"; // plane ui import { getButtonStyling } from "@plane/ui"; @@ -109,15 +109,19 @@ const PageDetailsPage = observer(() => { workspaceId, workspaceSlug: workspaceSlug?.toString() ?? "", }), - webhookConnectionParams: { - documentType: "project_page", - projectId: projectId?.toString() ?? "", - workspaceSlug: workspaceSlug?.toString() ?? "", - }, }), [getEditorFileHandlers, id, projectId, uploadEditorAsset, workspaceId, workspaceSlug] ); + const webhookConnectionParams: TWebhookConnectionQueryParams = useMemo( + () => ({ + documentType: "project_page", + projectId: projectId?.toString() ?? "", + workspaceSlug: workspaceSlug?.toString() ?? "", + }), + [projectId, workspaceSlug] + ); + if ((!page || !id) && !pageDetailsError) return (
@@ -150,6 +154,7 @@ const PageDetailsPage = observer(() => { config={pageRootConfig} handlers={pageRootHandlers} page={page} + webhookConnectionParams={webhookConnectionParams} workspaceSlug={workspaceSlug?.toString() ?? ""} /> diff --git a/web/core/components/pages/editor/editor-body.tsx b/web/core/components/pages/editor/editor-body.tsx index c2c57238f9b..72533c3dc04 100644 --- a/web/core/components/pages/editor/editor-body.tsx +++ b/web/core/components/pages/editor/editor-body.tsx @@ -34,7 +34,6 @@ import { TPageInstance } from "@/store/pages/base-page"; export type TEditorBodyConfig = { fileHandler: TFileHandler; - webhookConnectionParams: TWebhookConnectionQueryParams; }; export type TEditorBodyHandlers = { @@ -50,6 +49,7 @@ type Props = { handlers: TEditorBodyHandlers; page: TPageInstance; sidePeekVisible: boolean; + webhookConnectionParams: TWebhookConnectionQueryParams; workspaceSlug: string; }; @@ -62,6 +62,7 @@ export const PageEditorBody: React.FC = observer((props) => { handlers, page, sidePeekVisible, + webhookConnectionParams, workspaceSlug, } = props; // store hooks @@ -132,13 +133,13 @@ export const PageEditorBody: React.FC = observer((props) => { // Construct realtime config return { url: WS_LIVE_URL.toString(), - queryParams: config.webhookConnectionParams, + queryParams: webhookConnectionParams, }; } catch (error) { console.error("Error creating realtime config", error); return undefined; } - }, [config.webhookConnectionParams]); + }, [webhookConnectionParams]); const userConfig = useMemo( () => ({ diff --git a/web/core/components/pages/editor/page-root.tsx b/web/core/components/pages/editor/page-root.tsx index 64aef6912c0..bc9c497806f 100644 --- a/web/core/components/pages/editor/page-root.tsx +++ b/web/core/components/pages/editor/page-root.tsx @@ -4,7 +4,7 @@ import { useSearchParams } from "next/navigation"; // editor import { EditorRefApi } from "@plane/editor"; // types -import { TDocumentPayload, TPage, TPageVersion } from "@plane/types"; +import { TDocumentPayload, TPage, TPageVersion, TWebhookConnectionQueryParams } from "@plane/types"; // components import { PageEditorHeaderRoot, @@ -36,11 +36,12 @@ type TPageRootProps = { config: TPageRootConfig; handlers: TPageRootHandlers; page: TPageInstance; + webhookConnectionParams: TWebhookConnectionQueryParams; workspaceSlug: string; }; export const PageRoot = observer((props: TPageRootProps) => { - const { config, handlers, page, workspaceSlug } = props; + const { config, handlers, page, webhookConnectionParams, workspaceSlug } = props; // states const [editorReady, setEditorReady] = useState(false); const [hasConnectionFailed, setHasConnectionFailed] = useState(false); @@ -116,6 +117,7 @@ export const PageRoot = observer((props: TPageRootProps) => { handlers={handlers} page={page} sidePeekVisible={sidePeekVisible} + webhookConnectionParams={webhookConnectionParams} workspaceSlug={workspaceSlug} /> diff --git a/web/core/hooks/editor/use-editor-config.ts b/web/core/hooks/editor/use-editor-config.ts index 043dfa8fa20..166df1d5b32 100644 --- a/web/core/hooks/editor/use-editor-config.ts +++ b/web/core/hooks/editor/use-editor-config.ts @@ -20,7 +20,7 @@ type TArgs = { export const useEditorConfig = () => { // store hooks - const { getAssetUploadStatusByEditorBlockId } = useEditorAsset(); + const { assetsUploadPercentage } = useEditorAsset(); // file size const { maxFileSize } = useFileSize(); @@ -65,7 +65,7 @@ export const useEditorConfig = () => { workspaceId, workspaceSlug, }), - getAssetUploadStatus: (blockId) => getAssetUploadStatusByEditorBlockId(blockId)?.progress ?? 0, + assetsUploadStatus: assetsUploadPercentage, upload: uploadFile, delete: async (src: string) => { if (src?.startsWith("http")) { @@ -86,7 +86,7 @@ export const useEditorConfig = () => { }, }; }, - [getAssetUploadStatusByEditorBlockId, getReadOnlyEditorFileHandlers, maxFileSize] + [assetsUploadPercentage, getReadOnlyEditorFileHandlers, maxFileSize] ); return { diff --git a/web/core/store/editor/asset.store.ts b/web/core/store/editor/asset.store.ts index 80ed5e1b0e5..587acba023a 100644 --- a/web/core/store/editor/asset.store.ts +++ b/web/core/store/editor/asset.store.ts @@ -1,6 +1,6 @@ import debounce from "lodash/debounce"; import set from "lodash/set"; -import { action, makeObservable, observable, runInAction } from "mobx"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; import { v4 as uuidv4 } from "uuid"; // plane types @@ -10,8 +10,8 @@ import { FileService } from "@/services/file.service"; import { TAttachmentUploadStatus } from "../issue/issue-details/attachment.store"; export interface IEditorAssetStore { - // observables - assetUploadStatus: Record; // assetId => TAttachmentUploadStatus + // computed + assetsUploadPercentage: Record; // helper methods getAssetUploadStatusByEditorBlockId: (blockId: string) => TAttachmentUploadStatus | undefined; // actions @@ -32,14 +32,16 @@ export interface IEditorAssetStore { export class EditorAssetStore implements IEditorAssetStore { // observables - assetUploadStatus: Record = {}; + assetsUploadStatus: Record = {}; // services fileService: FileService; constructor() { makeObservable(this, { // observables - assetUploadStatus: observable, + assetsUploadStatus: observable, + // computed + assetsUploadPercentage: computed, // actions uploadEditorAsset: action, }); @@ -47,10 +49,20 @@ export class EditorAssetStore implements IEditorAssetStore { this.fileService = new FileService(); } + get assetsUploadPercentage() { + const assetsStatus = this.assetsUploadStatus; + const assetsPercentage: Record = {}; + Object.keys(assetsStatus).forEach((blockId) => { + const asset = assetsStatus[blockId]; + if (asset) assetsPercentage[blockId] = asset.progress; + }); + return assetsPercentage; + } + // helper methods getAssetUploadStatusByEditorBlockId: IEditorAssetStore["getAssetUploadStatusByEditorBlockId"] = computedFn( (blockId) => { - const blockDetails = this.assetUploadStatus[blockId]; + const blockDetails = this.assetsUploadStatus[blockId]; if (!blockDetails) return undefined; return blockDetails; } @@ -59,7 +71,7 @@ export class EditorAssetStore implements IEditorAssetStore { // actions private debouncedUpdateProgress = debounce((blockId: string, progress: number) => { runInAction(() => { - set(this.assetUploadStatus, [blockId, "progress"], progress); + set(this.assetsUploadStatus, [blockId, "progress"], progress); }); }, 16); @@ -70,7 +82,7 @@ export class EditorAssetStore implements IEditorAssetStore { try { // update attachment upload status runInAction(() => { - set(this.assetUploadStatus, [blockId], { + set(this.assetsUploadStatus, [blockId], { id: tempId, name: file.name, progress: 0, @@ -102,7 +114,7 @@ export class EditorAssetStore implements IEditorAssetStore { throw error; } finally { runInAction(() => { - delete this.assetUploadStatus[blockId]; + delete this.assetsUploadStatus[blockId]; }); } }; diff --git a/web/core/store/issue/issue-details/attachment.store.ts b/web/core/store/issue/issue-details/attachment.store.ts index f9e47a56055..6f4977dbb30 100644 --- a/web/core/store/issue/issue-details/attachment.store.ts +++ b/web/core/store/issue/issue-details/attachment.store.ts @@ -126,7 +126,7 @@ export class IssueAttachmentStore implements IIssueAttachmentStore { return response; }; - debouncedUpdateProgress = debounce((issueId: string, tempId: string, progress: number) => { + private debouncedUpdateProgress = debounce((issueId: string, tempId: string, progress: number) => { runInAction(() => { set(this.attachmentsUploadStatusMap, [issueId, tempId, "progress"], progress); }); From 8a42a24ac4ffda6e4a73fce0eaaee2ce872331ba Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Thu, 30 Jan 2025 19:47:10 +0530 Subject: [PATCH 5/7] chore: handle undefined status --- .../core/extensions/custom-image/components/upload-status.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/editor/src/core/extensions/custom-image/components/upload-status.tsx b/packages/editor/src/core/extensions/custom-image/components/upload-status.tsx index b3737d4fafb..c7e17e0766e 100644 --- a/packages/editor/src/core/extensions/custom-image/components/upload-status.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/upload-status.tsx @@ -14,6 +14,8 @@ export const ImageUploadStatus: React.FC = (props) => { selector: ({ editor }) => editor.storage.imageComponent.assetsUploadStatus[nodeId], }); + if (uploadStatus === undefined) return null; + return (
{uploadStatus}% From ce9e33558a18ad1965f3a46f3f6f935890252649 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Thu, 30 Jan 2025 19:48:08 +0530 Subject: [PATCH 6/7] chore: add type to upload status --- .../core/extensions/custom-image/components/upload-status.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor/src/core/extensions/custom-image/components/upload-status.tsx b/packages/editor/src/core/extensions/custom-image/components/upload-status.tsx index c7e17e0766e..de92cf606cf 100644 --- a/packages/editor/src/core/extensions/custom-image/components/upload-status.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/upload-status.tsx @@ -9,7 +9,7 @@ type Props = { export const ImageUploadStatus: React.FC = (props) => { const { editor, nodeId } = props; // subscribe to image upload status - const uploadStatus = useEditorState({ + const uploadStatus: number | undefined = useEditorState({ editor, selector: ({ editor }) => editor.storage.imageComponent.assetsUploadStatus[nodeId], }); From 528f226801d28473ecbac13a8b2f3d0ba4f1c389 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Thu, 30 Jan 2025 20:00:51 +0530 Subject: [PATCH 7/7] chore: added transition for upload status update --- .../custom-image/components/upload-status.tsx | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/core/extensions/custom-image/components/upload-status.tsx b/packages/editor/src/core/extensions/custom-image/components/upload-status.tsx index de92cf606cf..8492d51944b 100644 --- a/packages/editor/src/core/extensions/custom-image/components/upload-status.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/upload-status.tsx @@ -1,5 +1,6 @@ import { Editor } from "@tiptap/core"; import { useEditorState } from "@tiptap/react"; +import { useEffect, useRef, useState } from "react"; type Props = { editor: Editor; @@ -8,17 +9,52 @@ type Props = { export const ImageUploadStatus: React.FC = (props) => { const { editor, nodeId } = props; + // Displayed status that will animate smoothly + const [displayStatus, setDisplayStatus] = useState(0); + // Animation frame ID for cleanup + const animationFrameRef = useRef(null); // subscribe to image upload status const uploadStatus: number | undefined = useEditorState({ editor, selector: ({ editor }) => editor.storage.imageComponent.assetsUploadStatus[nodeId], }); + useEffect(() => { + const animateToValue = (start: number, end: number, startTime: number) => { + const duration = 200; + + const animation = (currentTime: number) => { + const elapsed = currentTime - startTime; + const progress = Math.min(elapsed / duration, 1); + + // Easing function for smooth animation + const easeOutCubic = 1 - Math.pow(1 - progress, 3); + + // Calculate current display value + const currentValue = Math.floor(start + (end - start) * easeOutCubic); + setDisplayStatus(currentValue); + + // Continue animation if not complete + if (progress < 1) { + animationFrameRef.current = requestAnimationFrame((time) => animation(time)); + } + }; + animationFrameRef.current = requestAnimationFrame((time) => animation(time)); + }; + animateToValue(displayStatus, uploadStatus, performance.now()); + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, [uploadStatus]); + if (uploadStatus === undefined) return null; return (
- {uploadStatus}% + {displayStatus}%
); };