diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/teamspaces/(detail)/[teamId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/teamspaces/(detail)/[teamId]/page.tsx index 1cdf032d94e..da784069c18 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/teamspaces/(detail)/[teamId]/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/teamspaces/(detail)/[teamId]/page.tsx @@ -7,6 +7,8 @@ // Teamspaces — mote. // See docs/mote-design/05-teamspaces-access.md, section 1. +"use client"; + import { useParams } from "next/navigation"; import { TeamspaceDetailRoot } from "@/plane-web/components/teamspaces"; diff --git a/packages/editor/src/core/constants/config.ts b/packages/editor/src/core/constants/config.ts index 9e126fa2a7e..7f8cf5d3ea5 100644 --- a/packages/editor/src/core/constants/config.ts +++ b/packages/editor/src/core/constants/config.ts @@ -16,6 +16,8 @@ export const DEFAULT_DISPLAY_CONFIG: TDisplayConfig = { export const ACCEPTED_IMAGE_MIME_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"]; +export const ACCEPTED_VIDEO_MIME_TYPES = ["video/mp4", "video/webm", "video/quicktime"]; + export const ACCEPTED_ATTACHMENT_MIME_TYPES = [ "image/jpeg", "image/png", diff --git a/packages/editor/src/core/constants/extension.ts b/packages/editor/src/core/constants/extension.ts index 3226cfd8d15..35ffb03420f 100644 --- a/packages/editor/src/core/constants/extension.ts +++ b/packages/editor/src/core/constants/extension.ts @@ -14,6 +14,7 @@ export enum CORE_EXTENSIONS { CODE_INLINE = "code", CUSTOM_COLOR = "customColor", CUSTOM_IMAGE = "imageComponent", + CUSTOM_VIDEO = "videoComponent", CUSTOM_LINK = "link", DOCUMENT = "doc", DROP_CURSOR = "dropCursor", @@ -72,6 +73,7 @@ export const BLOCK_NODE_TYPES = [ // Media and embed nodes CORE_EXTENSIONS.IMAGE, CORE_EXTENSIONS.CUSTOM_IMAGE, + CORE_EXTENSIONS.CUSTOM_VIDEO, CORE_EXTENSIONS.CALLOUT, CORE_EXTENSIONS.WORK_ITEM_EMBED, ]; diff --git a/packages/editor/src/core/extensions/custom-video/components/block.tsx b/packages/editor/src/core/extensions/custom-video/components/block.tsx new file mode 100644 index 00000000000..823167d275a --- /dev/null +++ b/packages/editor/src/core/extensions/custom-video/components/block.tsx @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Download } from "lucide-react"; +import { useCallback } from "react"; +// plane imports +import { cn } from "@plane/utils"; +// local imports +import type { CustomVideoNodeViewProps } from "./node-view"; + +type CustomVideoBlockProps = CustomVideoNodeViewProps & { + videoFromFileSystem: string | undefined; + setFailedToLoadVideo: (isError: boolean) => void; + src: string | undefined; + downloadSrc: string | undefined; +}; + +export function CustomVideoBlock(props: CustomVideoBlockProps) { + const { selected, setFailedToLoadVideo, src: resolvedVideoSrc, downloadSrc, videoFromFileSystem } = props; + + const videoSrc = videoFromFileSystem || resolvedVideoSrc; + + const handleError = useCallback(() => { + setFailedToLoadVideo(true); + }, [setFailedToLoadVideo]); + + const handleDownload = useCallback(() => { + if (!downloadSrc) return; + window.open(downloadSrc, "_blank", "noopener,noreferrer"); + }, [downloadSrc]); + + return ( +
+ + {downloadSrc && ( + + )} +
+ ); +} diff --git a/packages/editor/src/core/extensions/custom-video/components/node-view.tsx b/packages/editor/src/core/extensions/custom-video/components/node-view.tsx new file mode 100644 index 00000000000..b8614401bf4 --- /dev/null +++ b/packages/editor/src/core/extensions/custom-video/components/node-view.tsx @@ -0,0 +1,101 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { NodeViewWrapper } from "@tiptap/react"; +import type { NodeViewProps } from "@tiptap/react"; +import { useEffect, useRef, useState } from "react"; +// local imports +import type { CustomVideoExtensionType, TCustomVideoAttributes } from "../types"; +import { ECustomVideoAttributeNames } from "../types"; +import { CustomVideoBlock } from "./block"; +import { CustomVideoUploader } from "./uploader"; + +export type CustomVideoNodeViewProps = Omit & { + extension: CustomVideoExtensionType; + node: NodeViewProps["node"] & { + attrs: TCustomVideoAttributes; + }; + updateAttributes: (attrs: Partial) => void; +}; + +export function CustomVideoNodeView(props: CustomVideoNodeViewProps) { + const { extension, node } = props; + const { src: videoNodeSrc } = node.attrs; + + const [isUploaded, setIsUploaded] = useState(!!videoNodeSrc); + const [resolvedSrc, setResolvedSrc] = useState(undefined); + const [resolvedDownloadSrc, setResolvedDownloadSrc] = useState(undefined); + const [videoFromFileSystem, setVideoFromFileSystem] = useState(undefined); + const [failedToLoadVideo, setFailedToLoadVideo] = useState(false); + + const videoComponentRef = useRef(null); + + // the video is already uploaded if the video-component node has src attribute + // and we need to remove the blob from our file system + useEffect(() => { + if (resolvedSrc || videoNodeSrc) { + setIsUploaded(true); + setVideoFromFileSystem(undefined); + } else { + setIsUploaded(false); + } + }, [resolvedSrc, videoNodeSrc]); + + useEffect(() => { + if (!videoNodeSrc) { + setResolvedSrc(undefined); + setResolvedDownloadSrc(undefined); + return; + } + + setResolvedSrc(undefined); + setResolvedDownloadSrc(undefined); + setFailedToLoadVideo(false); + + const getVideoSource = async () => { + try { + const url = await extension.options.getVideoSource?.(videoNodeSrc); + setResolvedSrc(url); + const downloadUrl = await extension.options.getVideoDownloadSource?.(videoNodeSrc); + setResolvedDownloadSrc(downloadUrl); + } catch (error) { + console.error("Error fetching video source:", error); + setFailedToLoadVideo(true); + } + }; + void getVideoSource(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [videoNodeSrc, extension.options.getVideoSource, extension.options.getVideoDownloadSource]); + + const hasValidVideoSource = videoFromFileSystem || (isUploaded && resolvedSrc); + const shouldShowBlock = hasValidVideoSource && !failedToLoadVideo; + + return ( + +
+ {shouldShowBlock ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/packages/editor/src/core/extensions/custom-video/components/uploader.tsx b/packages/editor/src/core/extensions/custom-video/components/uploader.tsx new file mode 100644 index 00000000000..fc04f19fda1 --- /dev/null +++ b/packages/editor/src/core/extensions/custom-video/components/uploader.tsx @@ -0,0 +1,195 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { VideoIcon } from "lucide-react"; +import type { ChangeEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +// plane imports +import { cn } from "@plane/utils"; +// constants +import { ACCEPTED_VIDEO_MIME_TYPES } from "@/constants/config"; +// helpers +import type { EFileError } from "@/helpers/file"; +// hooks +import { useUploader, useDropZone, uploadFirstFileAndInsertRemaining } from "@/hooks/use-file-upload"; +// local imports +import { ECustomVideoStatus } from "../types"; +import { getVideoComponentFileMap } from "../utils"; +import type { CustomVideoNodeViewProps } from "./node-view"; + +type CustomVideoUploaderProps = CustomVideoNodeViewProps & { + failedToLoadVideo: boolean; + loadVideoFromFileSystem: (file: string) => void; + maxFileSize: number; + setIsUploaded: (isUploaded: boolean) => void; +}; + +export function CustomVideoUploader(props: CustomVideoUploaderProps) { + const { + editor, + extension, + failedToLoadVideo, + getPos, + loadVideoFromFileSystem, + maxFileSize, + node, + selected, + setIsUploaded, + updateAttributes, + } = props; + // refs + const fileInputRef = useRef(null); + const hasTriggeredFilePickerRef = useRef(false); + const hasTriedUploadingOnMountRef = useRef(false); + const { id: videoEntityId } = node.attrs; + // derived values + const videoComponentFileMap = useMemo(() => getVideoComponentFileMap(editor), [editor]); + const isTouchDevice = !!(editor.storage.utility as { isTouchDevice?: boolean } | undefined)?.isTouchDevice; + + const onUpload = useCallback( + (url: string) => { + if (url) { + if (!videoEntityId) return; + setIsUploaded(true); + updateAttributes({ + src: url, + status: ECustomVideoStatus.UPLOADED, + }); + videoComponentFileMap?.delete(videoEntityId); + } + }, + [videoComponentFileMap, videoEntityId, updateAttributes, setIsUploaded] + ); + + const uploadVideoEditorCommand = useCallback( + async (file: File) => { + updateAttributes({ status: ECustomVideoStatus.UPLOADING }); + return await extension.options.uploadVideo?.(videoEntityId ?? "", file); + }, + [extension.options, videoEntityId, updateAttributes] + ); + + const handleProgressStatus = useCallback( + (isUploading: boolean) => { + editor.storage.utility.uploadInProgress = isUploading; + }, + [editor] + ); + + const handleInvalidFile = useCallback((_error: EFileError, _file: File, message: string) => { + alert(message); + }, []); + + // hooks + const { isUploading: isVideoBeingUploaded, uploadFile } = useUploader({ + acceptedMimeTypes: ACCEPTED_VIDEO_MIME_TYPES, + editorCommand: uploadVideoEditorCommand, + handleProgressStatus, + loadFileFromFileSystem: loadVideoFromFileSystem, + maxFileSize, + onInvalidFile: handleInvalidFile, + onUpload, + }); + + const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ + editor, + getPos, + type: "attachment", + uploader: uploadFile, + }); + + // after the video component is mounted we start the upload process based on + // it's uploaded + useEffect(() => { + if (hasTriedUploadingOnMountRef.current) return; + + const meta = videoComponentFileMap?.get(videoEntityId ?? ""); + if (meta) { + if (meta.event === "drop" && "file" in meta) { + hasTriedUploadingOnMountRef.current = true; + uploadFile(meta.file); + } else if (meta.event === "insert" && fileInputRef.current && !hasTriggeredFilePickerRef.current) { + if (meta.hasOpenedFileInputOnce) return; + if (!isTouchDevice) { + fileInputRef.current.click(); + } + hasTriggeredFilePickerRef.current = true; + videoComponentFileMap?.set(videoEntityId ?? "", { ...meta, hasOpenedFileInputOnce: true }); + } + } else { + hasTriedUploadingOnMountRef.current = true; + } + }, [videoEntityId, isTouchDevice, uploadFile, videoComponentFileMap]); + + const onFileChange = useCallback( + async (e: ChangeEvent) => { + e.preventDefault(); + const filesList = e.target.files; + const pos = getPos(); + if (!filesList || pos === undefined) { + return; + } + await uploadFirstFileAndInsertRemaining({ + editor, + filesList, + pos, + type: "attachment", + uploader: uploadFile, + }); + }, + [uploadFile, editor, getPos] + ); + + const getDisplayMessage = useCallback(() => { + if (failedToLoadVideo) { + return "Error loading video"; + } + if (isVideoBeingUploaded) { + return "Uploading..."; + } + if (draggedInside && editor.isEditable) { + return "Drop video here"; + } + return "Add a video"; + }, [draggedInside, editor.isEditable, failedToLoadVideo, isVideoBeingUploaded]); + + return ( +
{ + if (!failedToLoadVideo && editor.isEditable) { + fileInputRef.current?.click(); + } + }} + > + +
{getDisplayMessage()}
+ +
+ ); +} diff --git a/packages/editor/src/core/extensions/custom-video/extension-config.ts b/packages/editor/src/core/extensions/custom-video/extension-config.ts new file mode 100644 index 00000000000..91f6518ad80 --- /dev/null +++ b/packages/editor/src/core/extensions/custom-video/extension-config.ts @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { Node, mergeAttributes } from "@tiptap/core"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; +// local imports +import { ECustomVideoAttributeNames } from "./types"; +import type { + CustomVideoExtensionOptions, + TCustomVideoAttributes, + CustomVideoExtensionType, + CustomVideoExtensionStorage, + InsertVideoComponentProps, +} from "./types"; +import { DEFAULT_CUSTOM_VIDEO_ATTRIBUTES } from "./utils"; + +declare module "@tiptap/core" { + interface Commands { + [CORE_EXTENSIONS.CUSTOM_VIDEO]: { + insertVideoComponent: ({ file, pos, event }: InsertVideoComponentProps) => ReturnType; + }; + } + interface Storage { + [CORE_EXTENSIONS.CUSTOM_VIDEO]: CustomVideoExtensionStorage; + } +} + +export const CustomVideoExtensionConfig: CustomVideoExtensionType = Node.create< + CustomVideoExtensionOptions, + CustomVideoExtensionStorage +>({ + name: CORE_EXTENSIONS.CUSTOM_VIDEO, + group: "block", + atom: true, + + addAttributes() { + return Object.values(ECustomVideoAttributeNames).reduce( + (acc, value) => { + acc[value] = { + default: DEFAULT_CUSTOM_VIDEO_ATTRIBUTES[value], + }; + return acc; + }, + {} as Record + ); + }, + + parseHTML() { + return [ + { + tag: "video-component", + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ["video-component", mergeAttributes(HTMLAttributes)]; + }, +}); diff --git a/packages/editor/src/core/extensions/custom-video/extension.tsx b/packages/editor/src/core/extensions/custom-video/extension.tsx new file mode 100644 index 00000000000..63099c69a43 --- /dev/null +++ b/packages/editor/src/core/extensions/custom-video/extension.tsx @@ -0,0 +1,130 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { v4 as uuidv4 } from "uuid"; +// constants +import { ACCEPTED_VIDEO_MIME_TYPES } from "@/constants/config"; +// helpers +import { isFileValid } from "@/helpers/file"; +import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary"; +// types +import type { TFileHandler } from "@/types"; +// local imports +import type { CustomVideoNodeViewProps } from "./components/node-view"; +import { CustomVideoNodeView } from "./components/node-view"; +import { CustomVideoExtensionConfig } from "./extension-config"; +import type { CustomVideoExtensionOptions, CustomVideoExtensionStorage } from "./types"; +import { ECustomVideoAttributeNames, ECustomVideoStatus } from "./types"; +import { getVideoComponentFileMap } from "./utils"; + +type Props = { + fileHandler: TFileHandler; + isEditable: boolean; +}; + +export function CustomVideoExtension(props: Props) { + const { fileHandler, isEditable } = props; + // derived values + const { getAssetSrc, getAssetDownloadSrc } = fileHandler; + + return CustomVideoExtensionConfig.extend({ + selectable: isEditable, + draggable: isEditable, + + addOptions() { + const upload = "upload" in fileHandler ? fileHandler.upload : undefined; + return { + ...this.parent?.(), + getVideoDownloadSource: getAssetDownloadSrc, + getVideoSource: getAssetSrc, + uploadVideo: upload, + }; + }, + + addStorage() { + const maxFileSize = "validation" in fileHandler ? fileHandler.validation?.maxFileSize : 0; + + return { + fileMap: new Map(), + maxFileSize, + // escape markdown for videos + markdown: { + serialize() {}, + }, + }; + }, + + addCommands() { + return { + insertVideoComponent: + (props) => + ({ commands }) => { + // Early return if there's an invalid file being dropped + if ( + props?.file && + !isFileValid({ + acceptedMimeTypes: ACCEPTED_VIDEO_MIME_TYPES, + file: props.file, + maxFileSize: this.storage.maxFileSize, + onError: (_error, message) => alert(message), + }) + ) { + return false; + } + + // generate a unique id for the video to keep track of dropped + // files' file data + const fileId = uuidv4(); + + const videoComponentFileMap = getVideoComponentFileMap(this.editor); + + if (videoComponentFileMap) { + if (props?.event === "drop" && props.file) { + videoComponentFileMap.set(fileId, { + file: props.file, + event: props.event, + }); + } else if (props.event === "insert") { + videoComponentFileMap.set(fileId, { + event: props.event, + hasOpenedFileInputOnce: false, + }); + } + } + + const attributes = { + [ECustomVideoAttributeNames.ID]: fileId, + [ECustomVideoAttributeNames.STATUS]: ECustomVideoStatus.PENDING, + }; + + if (props.pos) { + return commands.insertContentAt(props.pos, { + type: this.name, + attrs: attributes, + }); + } + return commands.insertContent({ + type: this.name, + attrs: attributes, + }); + }, + }; + }, + + addKeyboardShortcuts() { + return { + ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name), + ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name), + }; + }, + addNodeView() { + return ReactNodeViewRenderer((props) => ( + + )); + }, + }); +} diff --git a/packages/editor/src/core/extensions/custom-video/types.ts b/packages/editor/src/core/extensions/custom-video/types.ts new file mode 100644 index 00000000000..453e119c0e4 --- /dev/null +++ b/packages/editor/src/core/extensions/custom-video/types.ts @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { Node } from "@tiptap/core"; +// types +import type { TFileHandler } from "@/types"; + +export enum ECustomVideoAttributeNames { + ID = "id", + SOURCE = "src", + STATUS = "status", +} + +export enum ECustomVideoStatus { + PENDING = "pending", + UPLOADING = "uploading", + UPLOADED = "uploaded", +} + +export type TCustomVideoAttributes = { + [ECustomVideoAttributeNames.ID]: string | null; + [ECustomVideoAttributeNames.SOURCE]: string | null; + [ECustomVideoAttributeNames.STATUS]: ECustomVideoStatus; +}; + +export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean }; + +export type InsertVideoComponentProps = { + file?: File; + pos?: number; + event: "insert" | "drop"; +}; + +export type CustomVideoExtensionOptions = { + getVideoSource: TFileHandler["getAssetSrc"]; + getVideoDownloadSource: TFileHandler["getAssetDownloadSrc"]; + uploadVideo?: TFileHandler["upload"]; +}; + +export type CustomVideoExtensionStorage = { + fileMap: Map; + maxFileSize: number; +}; + +export type CustomVideoExtensionType = Node; diff --git a/packages/editor/src/core/extensions/custom-video/utils.ts b/packages/editor/src/core/extensions/custom-video/utils.ts new file mode 100644 index 00000000000..bef331562f0 --- /dev/null +++ b/packages/editor/src/core/extensions/custom-video/utils.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { Editor } from "@tiptap/core"; +// local imports +import { ECustomVideoAttributeNames, ECustomVideoStatus } from "./types"; +import type { TCustomVideoAttributes } from "./types"; + +export const DEFAULT_CUSTOM_VIDEO_ATTRIBUTES: TCustomVideoAttributes = { + [ECustomVideoAttributeNames.SOURCE]: null, + [ECustomVideoAttributeNames.ID]: null, + [ECustomVideoAttributeNames.STATUS]: ECustomVideoStatus.PENDING, +}; + +export const getVideoComponentFileMap = (editor: Editor) => editor.storage.videoComponent?.fileMap; diff --git a/packages/editor/src/core/extensions/extensions.ts b/packages/editor/src/core/extensions/extensions.ts index 420e749507f..ac1017a3f8c 100644 --- a/packages/editor/src/core/extensions/extensions.ts +++ b/packages/editor/src/core/extensions/extensions.ts @@ -39,6 +39,7 @@ import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions"; import type { IEditorProps } from "@/types"; // local imports import { CustomImageExtension } from "./custom-image/extension"; +import { CustomVideoExtension } from "./custom-video/extension"; import { EmojiExtension } from "./emoji/extension"; import { CustomPlaceholderExtension } from "./placeholder"; import { CustomStarterKitExtension } from "./starter-kit"; @@ -152,5 +153,14 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => { ); } + if (!disabledExtensions.includes("video")) { + extensions.push( + CustomVideoExtension({ + fileHandler, + isEditable: editable, + }) + ); + } + return extensions; }; diff --git a/packages/editor/src/core/plugins/drop.ts b/packages/editor/src/core/plugins/drop.ts index e0e9057a5c2..7076482ab50 100644 --- a/packages/editor/src/core/plugins/drop.ts +++ b/packages/editor/src/core/plugins/drop.ts @@ -7,7 +7,11 @@ import type { Editor } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; // constants -import { ACCEPTED_ATTACHMENT_MIME_TYPES, ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config"; +import { + ACCEPTED_ATTACHMENT_MIME_TYPES, + ACCEPTED_IMAGE_MIME_TYPES, + ACCEPTED_VIDEO_MIME_TYPES, +} from "@/constants/config"; // types import type { TEditorCommands, TExtensions } from "@/types"; @@ -126,7 +130,12 @@ export const insertFilesSafely = async (args: InsertFilesSafelyArgs) => { pos, event, }); - } else if (fileType === "attachment") { + } else if (fileType === "attachment" && ACCEPTED_VIDEO_MIME_TYPES.includes(file.type) && !disabledExtensions?.includes("video")) { + editor.commands.insertVideoComponent({ + file, + pos, + event, + }); } } catch (error) { console.error(`Error while ${event}ing file:`, error); diff --git a/packages/editor/src/core/types/extensions.ts b/packages/editor/src/core/types/extensions.ts index 2670b6c0398..82d80ee1490 100644 --- a/packages/editor/src/core/types/extensions.ts +++ b/packages/editor/src/core/types/extensions.ts @@ -4,4 +4,4 @@ * See the LICENSE file for details. */ -export type TExtensions = "ai" | "collaboration-cursor" | "issue-embed" | "slash-commands" | "enter-key" | "image"; +export type TExtensions = "ai" | "collaboration-cursor" | "issue-embed" | "slash-commands" | "enter-key" | "image" | "video";