Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
2 changes: 2 additions & 0 deletions packages/editor/src/core/constants/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/editor/src/core/constants/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
];
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={cn("group/video-block relative my-1 max-w-full rounded-md", {
"outline outline-2 outline-offset-2 outline-[var(--border-color-accent-strong)]": selected,
})}
contentEditable={false}
>
<video
src={videoSrc}
controls
preload="metadata"
className="max-h-[480px] max-w-full rounded-md bg-layer-3"
onError={handleError}
>
Your browser does not support playing this video.
</video>
{downloadSrc && (
<button
type="button"
onClick={handleDownload}
title="Download video"
className="absolute right-2 top-2 flex items-center gap-1 rounded-md bg-black/60 px-2 py-1 text-11 text-white opacity-0 transition-opacity duration-150 group-hover/video-block:opacity-100"
>
<Download className="size-3.5" />
</button>
)}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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<NodeViewProps, "extension" | "updateAttributes"> & {
extension: CustomVideoExtensionType;
node: NodeViewProps["node"] & {
attrs: TCustomVideoAttributes;
};
updateAttributes: (attrs: Partial<TCustomVideoAttributes>) => void;
};

export function CustomVideoNodeView(props: CustomVideoNodeViewProps) {
const { extension, node } = props;
const { src: videoNodeSrc } = node.attrs;

const [isUploaded, setIsUploaded] = useState(!!videoNodeSrc);
const [resolvedSrc, setResolvedSrc] = useState<string | undefined>(undefined);
const [resolvedDownloadSrc, setResolvedDownloadSrc] = useState<string | undefined>(undefined);
const [videoFromFileSystem, setVideoFromFileSystem] = useState<string | undefined>(undefined);
const [failedToLoadVideo, setFailedToLoadVideo] = useState(false);

const videoComponentRef = useRef<HTMLDivElement>(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 (
<NodeViewWrapper key={node.attrs[ECustomVideoAttributeNames.ID]}>
<div className="mx-0 my-2 p-0" data-drag-handle ref={videoComponentRef}>
{shouldShowBlock ? (
<CustomVideoBlock
src={resolvedSrc}
downloadSrc={resolvedDownloadSrc}
videoFromFileSystem={videoFromFileSystem}
setFailedToLoadVideo={setFailedToLoadVideo}
{...props}
/>
) : (
<CustomVideoUploader
failedToLoadVideo={failedToLoadVideo}
loadVideoFromFileSystem={setVideoFromFileSystem}
maxFileSize={
(props.editor.storage.videoComponent as { maxFileSize?: number } | undefined)?.maxFileSize ?? 0
}
setIsUploaded={setIsUploaded}
{...props}
/>
)}
</div>
</NodeViewWrapper>
);
}
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div
className={cn(
"video-upload-component flex cursor-default items-center justify-start gap-2 rounded-lg border border-dashed bg-layer-3 px-2 py-3 text-tertiary transition-all duration-200 ease-in-out",
{
"border-subtle": !(selected && editor.isEditable && !failedToLoadVideo),
"cursor-pointer hover:bg-layer-3-hover hover:text-secondary": editor.isEditable && !failedToLoadVideo,
"bg-layer-3-hover text-secondary": draggedInside && editor.isEditable && !failedToLoadVideo,
"bg-accent-primary/10 text-accent-secondary hover:bg-accent-primary/10 hover:text-accent-secondary":
selected && editor.isEditable && !failedToLoadVideo,
"cursor-default bg-danger-subtle text-danger-primary": failedToLoadVideo,
}
)}
onDrop={onDrop}
onDragOver={onDragEnter}
onDragLeave={onDragLeave}
contentEditable={false}
onClick={() => {
if (!failedToLoadVideo && editor.isEditable) {
fileInputRef.current?.click();
}
}}
>
<VideoIcon className="size-4" />
<div className="flex-1 text-14 font-medium">{getDisplayMessage()}</div>
<input
className="size-0 overflow-hidden"
ref={fileInputRef}
hidden
type="file"
accept={ACCEPTED_VIDEO_MIME_TYPES.join(",")}
onChange={onFileChange}
multiple
/>
</div>
);
}
Loading