mirror of
https://github.com/makeplane/plane.git
synced 2026-07-12 21:40:18 +02:00
Merge pull request #1302 from makeplane/preview
promote: preview to uat
This commit is contained in:
@@ -29,6 +29,8 @@ from plane.db.models import (
|
||||
IssueComment,
|
||||
CommentReaction,
|
||||
)
|
||||
from plane.payment.flags.flag_decorator import check_workspace_feature_flag
|
||||
from plane.payment.flags.flag import FeatureFlag
|
||||
|
||||
|
||||
class IssueActivityEndpoint(BaseAPIView):
|
||||
@@ -61,6 +63,13 @@ class IssueActivityEndpoint(BaseAPIView):
|
||||
.filter(**filters)
|
||||
.select_related("actor", "workspace", "issue", "project")
|
||||
).order_by("created_at")
|
||||
|
||||
if not check_workspace_feature_flag(
|
||||
feature_key=FeatureFlag.ISSUE_TYPE_SETTINGS,
|
||||
slug=slug,
|
||||
):
|
||||
issue_activities = issue_activities.filter(field="type")
|
||||
|
||||
issue_comments = (
|
||||
IssueComment.objects.filter(issue_id=issue_id)
|
||||
.filter(
|
||||
|
||||
@@ -27,6 +27,7 @@ from plane.db.models import (
|
||||
State,
|
||||
User,
|
||||
EstimatePoint,
|
||||
IssueType,
|
||||
)
|
||||
from plane.settings.redis import redis_instance
|
||||
from plane.utils.exception_logger import log_exception
|
||||
@@ -570,6 +571,51 @@ def track_closed_to(
|
||||
)
|
||||
|
||||
|
||||
def track_type(
|
||||
requested_data,
|
||||
current_instance,
|
||||
issue_id,
|
||||
project_id,
|
||||
workspace_id,
|
||||
actor_id,
|
||||
issue_activities,
|
||||
epoch,
|
||||
):
|
||||
|
||||
new_type_id = requested_data.get("type_id")
|
||||
old_type_id = current_instance.get("type_id")
|
||||
|
||||
if new_type_id != old_type_id:
|
||||
verb = "updated" if new_type_id else "deleted"
|
||||
old_type_id = (
|
||||
IssueType.objects.filter(pk=old_type_id).first()
|
||||
if old_type_id
|
||||
else None
|
||||
)
|
||||
new_type_id = (
|
||||
IssueType.objects.filter(pk=new_type_id).first()
|
||||
if new_type_id
|
||||
else None
|
||||
)
|
||||
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor_id=actor_id,
|
||||
verb=verb,
|
||||
old_value=old_type_id.name if old_type_id else None,
|
||||
new_value=new_type_id.name if new_type_id else None,
|
||||
field="type",
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment="",
|
||||
old_identifier=old_type_id.id if old_type_id else None,
|
||||
new_identifier=new_type_id.id if new_type_id else None,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def create_issue_activity(
|
||||
requested_data,
|
||||
current_instance,
|
||||
@@ -632,6 +678,7 @@ def update_issue_activity(
|
||||
"estimate_point": track_estimate_points,
|
||||
"archived_at": track_archive_at,
|
||||
"closed_to": track_closed_to,
|
||||
"type_id": track_type,
|
||||
}
|
||||
|
||||
requested_data = (
|
||||
@@ -1700,16 +1747,12 @@ def issue_activity(
|
||||
event=(
|
||||
"issue_comment"
|
||||
if activity.field == "comment"
|
||||
else "inbox_issue"
|
||||
if inbox
|
||||
else "issue"
|
||||
else "inbox_issue" if inbox else "issue"
|
||||
),
|
||||
event_id=(
|
||||
activity.issue_comment_id
|
||||
if activity.field == "comment"
|
||||
else inbox
|
||||
if inbox
|
||||
else activity.issue_id
|
||||
else inbox if inbox else activity.issue_id
|
||||
),
|
||||
verb=activity.verb,
|
||||
field=(
|
||||
@@ -1755,4 +1798,3 @@ def issue_activity(
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
|
||||
@@ -67,19 +67,20 @@
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.8.9",
|
||||
"uuid": "^10.0.0",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"y-prosemirror": "^1.2.5",
|
||||
"y-protocols": "^1.0.6",
|
||||
"yjs": "^13.6.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "*",
|
||||
"@plane/typescript-config": "*",
|
||||
"@types/node": "18.15.3",
|
||||
"@types/react": "^18.2.42",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@plane/eslint-config": "*",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwind-config-custom": "*",
|
||||
"@plane/typescript-config": "*",
|
||||
"tsup": "^7.2.0",
|
||||
"typescript": "5.3.3"
|
||||
},
|
||||
|
||||
@@ -18,7 +18,8 @@ interface EditorContainerProps {
|
||||
export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
||||
const { children, displayConfig, editor, editorContainerClassName, id } = props;
|
||||
|
||||
const handleContainerClick = () => {
|
||||
const handleContainerClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (!editor) return;
|
||||
if (!editor.isEditable) return;
|
||||
try {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "lucide-react";
|
||||
// helpers
|
||||
import {
|
||||
insertImage,
|
||||
insertTableCommand,
|
||||
setText,
|
||||
toggleBlockquote,
|
||||
@@ -193,8 +194,7 @@ export const ImageItem = (editor: Editor) =>
|
||||
key: "image",
|
||||
name: "Image",
|
||||
isActive: () => editor?.isActive("image") || editor?.isActive("imageComponent"),
|
||||
command: (savedSelection: Selection | null) =>
|
||||
editor?.commands.setImageUpload({ event: "insert", pos: savedSelection?.from }),
|
||||
command: (savedSelection: Selection | null) => insertImage({ editor, event: "insert", pos: savedSelection?.from }),
|
||||
icon: ImageIcon,
|
||||
}) as const;
|
||||
|
||||
|
||||
@@ -7,105 +7,169 @@ import { cn } from "@/helpers/common";
|
||||
|
||||
const MIN_SIZE = 100;
|
||||
|
||||
export const CustomImageBlock: React.FC<CustomImageNodeViewProps> = (props) => {
|
||||
const { node, updateAttributes, selected, getPos, editor } = props;
|
||||
const { src, width, height } = node.attrs;
|
||||
type Pixel = `${number}px`;
|
||||
|
||||
const [size, setSize] = useState({
|
||||
width: width?.toString() || "35%",
|
||||
height: height?.toString() || "auto",
|
||||
type PixelAttribute<TDefault> = Pixel | TDefault;
|
||||
|
||||
export type ImageAttributes = {
|
||||
src: string | null;
|
||||
width: PixelAttribute<"35%" | number>;
|
||||
height: PixelAttribute<"auto" | number>;
|
||||
aspectRatio: number | null;
|
||||
id: string | null;
|
||||
};
|
||||
|
||||
type Size = {
|
||||
width: PixelAttribute<"35%">;
|
||||
height: PixelAttribute<"auto">;
|
||||
aspectRatio: number | null;
|
||||
};
|
||||
|
||||
const ensurePixelString = <TDefault,>(value: Pixel | TDefault | number | undefined | null, defaultValue?: TDefault) => {
|
||||
if (!value || value === defaultValue) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return `${value}px` satisfies Pixel;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
type CustomImageBlockProps = CustomImageNodeViewProps & {
|
||||
imageFromFileSystem: string;
|
||||
setFailedToLoadImage: (isError: boolean) => void;
|
||||
editorContainer: HTMLDivElement | null;
|
||||
setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
|
||||
};
|
||||
|
||||
export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
// props
|
||||
const {
|
||||
node,
|
||||
updateAttributes,
|
||||
setFailedToLoadImage,
|
||||
imageFromFileSystem,
|
||||
selected,
|
||||
getPos,
|
||||
editor,
|
||||
editorContainer,
|
||||
setEditorContainer,
|
||||
} = props;
|
||||
const { src: remoteImageSrc, width, height, aspectRatio } = node.attrs;
|
||||
// states
|
||||
const [size, setSize] = useState<Size>({
|
||||
width: ensurePixelString(width, "35%"),
|
||||
height: ensurePixelString(height, "auto"),
|
||||
aspectRatio: aspectRatio || 1,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [initialResizeComplete, setInitialResizeComplete] = useState(false);
|
||||
const isShimmerVisible = isLoading || !initialResizeComplete;
|
||||
const [editorContainer, setEditorContainer] = useState<HTMLElement | null>(null);
|
||||
|
||||
// refs
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const containerRect = useRef<DOMRect | null>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const isResizing = useRef(false);
|
||||
const aspectRatioRef = useRef<number | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (imageRef.current) {
|
||||
const img = imageRef.current;
|
||||
img.onload = () => {
|
||||
const closestEditorContainer = img.closest(".editor-container");
|
||||
if (!closestEditorContainer) {
|
||||
console.error("Editor container not found");
|
||||
return;
|
||||
}
|
||||
const handleImageLoad = useCallback(() => {
|
||||
const img = imageRef.current;
|
||||
if (!img) return;
|
||||
let closestEditorContainer: HTMLDivElement | null = null;
|
||||
|
||||
setEditorContainer(closestEditorContainer as HTMLElement);
|
||||
|
||||
if (width === "35%") {
|
||||
const editorWidth = closestEditorContainer.clientWidth;
|
||||
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
|
||||
const aspectRatio = img.naturalWidth / img.naturalHeight;
|
||||
const initialHeight = initialWidth / aspectRatio;
|
||||
|
||||
const newSize = {
|
||||
width: `${Math.round(initialWidth)}px`,
|
||||
height: `${Math.round(initialHeight)}px`,
|
||||
};
|
||||
|
||||
setSize(newSize);
|
||||
updateAttributes(newSize);
|
||||
}
|
||||
setInitialResizeComplete(true);
|
||||
setIsLoading(false);
|
||||
};
|
||||
}
|
||||
}, [width, height, updateAttributes]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setSize({
|
||||
width: width?.toString(),
|
||||
height: height?.toString(),
|
||||
});
|
||||
}, [width, height]);
|
||||
|
||||
const handleResizeStart = useCallback(
|
||||
(e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isResizing.current = true;
|
||||
if (containerRef.current && editorContainer) {
|
||||
aspectRatioRef.current = Number(size.width.replace("px", "")) / Number(size.height.replace("px", ""));
|
||||
containerRect.current = containerRef.current.getBoundingClientRect();
|
||||
if (editorContainer) {
|
||||
closestEditorContainer = editorContainer;
|
||||
} else {
|
||||
closestEditorContainer = img.closest(".editor-container") as HTMLDivElement | null;
|
||||
if (!closestEditorContainer) {
|
||||
console.error("Editor container not found");
|
||||
return;
|
||||
}
|
||||
},
|
||||
[size, editorContainer]
|
||||
);
|
||||
}
|
||||
if (!closestEditorContainer) {
|
||||
console.error("Editor container not found");
|
||||
return;
|
||||
}
|
||||
|
||||
setEditorContainer(closestEditorContainer);
|
||||
const aspectRatio = img.naturalWidth / img.naturalHeight;
|
||||
|
||||
if (width === "35%") {
|
||||
const editorWidth = closestEditorContainer.clientWidth;
|
||||
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
|
||||
const initialHeight = initialWidth / aspectRatio;
|
||||
|
||||
const initialComputedSize = {
|
||||
width: `${Math.round(initialWidth)}px` satisfies Pixel,
|
||||
height: `${Math.round(initialHeight)}px` satisfies Pixel,
|
||||
aspectRatio: aspectRatio,
|
||||
};
|
||||
|
||||
setSize(initialComputedSize);
|
||||
updateAttributes(initialComputedSize);
|
||||
} else {
|
||||
// as the aspect ratio in not stored for old images, we need to update the attrs
|
||||
setSize((prevSize) => {
|
||||
const newSize = { ...prevSize, aspectRatio };
|
||||
updateAttributes(newSize);
|
||||
return newSize;
|
||||
});
|
||||
}
|
||||
setInitialResizeComplete(true);
|
||||
}, [width, updateAttributes, editorContainer]);
|
||||
|
||||
// for real time resizing
|
||||
useLayoutEffect(() => {
|
||||
setSize((prevSize) => ({
|
||||
...prevSize,
|
||||
width: ensurePixelString(width),
|
||||
height: ensurePixelString(height),
|
||||
}));
|
||||
}, [width, height]);
|
||||
|
||||
const handleResize = useCallback(
|
||||
(e: MouseEvent | TouchEvent) => {
|
||||
if (!isResizing.current || !containerRef.current || !containerRect.current) return;
|
||||
|
||||
if (size) {
|
||||
aspectRatioRef.current = Number(size.width.replace("px", "")) / Number(size.height.replace("px", ""));
|
||||
}
|
||||
|
||||
if (!aspectRatioRef.current) return;
|
||||
if (!containerRef.current || !containerRect.current || !size.aspectRatio) return;
|
||||
|
||||
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
|
||||
|
||||
const newWidth = Math.max(clientX - containerRect.current.left, MIN_SIZE);
|
||||
const newHeight = newWidth / aspectRatioRef.current;
|
||||
const newHeight = newWidth / size.aspectRatio;
|
||||
|
||||
setSize({ width: `${newWidth}px`, height: `${newHeight}px` });
|
||||
setSize((prevSize) => ({ ...prevSize, width: `${newWidth}px`, height: `${newHeight}px` }));
|
||||
},
|
||||
[size]
|
||||
);
|
||||
|
||||
const handleResizeEnd = useCallback(() => {
|
||||
if (isResizing.current) {
|
||||
isResizing.current = false;
|
||||
updateAttributes(size);
|
||||
}
|
||||
setIsResizing(false);
|
||||
updateAttributes(size);
|
||||
}, [size, updateAttributes]);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsResizing(true);
|
||||
|
||||
if (containerRef.current) {
|
||||
containerRect.current = containerRef.current.getBoundingClientRect();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isResizing) {
|
||||
window.addEventListener("mousemove", handleResize);
|
||||
window.addEventListener("mouseup", handleResizeEnd);
|
||||
window.addEventListener("mouseleave", handleResizeEnd);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleResize);
|
||||
window.removeEventListener("mouseup", handleResizeEnd);
|
||||
window.removeEventListener("mouseleave", handleResizeEnd);
|
||||
};
|
||||
}
|
||||
}, [isResizing, handleResize, handleResizeEnd]);
|
||||
|
||||
const handleImageMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const pos = getPos();
|
||||
@@ -115,65 +179,86 @@ export const CustomImageBlock: React.FC<CustomImageNodeViewProps> = (props) => {
|
||||
[editor, getPos]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorContainer) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => handleResize(e);
|
||||
const handleMouseUp = () => handleResizeEnd();
|
||||
const handleMouseLeave = () => handleResizeEnd();
|
||||
|
||||
editorContainer.addEventListener("mousemove", handleMouseMove);
|
||||
editorContainer.addEventListener("mouseup", handleMouseUp);
|
||||
editorContainer.addEventListener("mouseleave", handleMouseLeave);
|
||||
|
||||
return () => {
|
||||
editorContainer.removeEventListener("mousemove", handleMouseMove);
|
||||
editorContainer.removeEventListener("mouseup", handleMouseUp);
|
||||
editorContainer.removeEventListener("mouseleave", handleMouseLeave);
|
||||
};
|
||||
}, [handleResize, handleResizeEnd, editorContainer]);
|
||||
// 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 = !(remoteImageSrc || imageFromFileSystem) || !initialResizeComplete;
|
||||
// show the image utils 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)
|
||||
const showImageUtils = editor.isEditable && remoteImageSrc && initialResizeComplete;
|
||||
// show the preview image from the file system if the remote image's src is not set
|
||||
const displayedImageSrc = remoteImageSrc ?? imageFromFileSystem;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="group/image-component relative inline-block max-w-full"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseDown={handleImageMouseDown}
|
||||
style={{
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
aspectRatio: size.aspectRatio,
|
||||
}}
|
||||
>
|
||||
{isShimmerVisible && (
|
||||
<div className="animate-pulse bg-custom-background-80 rounded-md" style={{ width, height }} />
|
||||
{showImageLoader && (
|
||||
<div
|
||||
className="animate-pulse bg-custom-background-80 rounded-md"
|
||||
style={{ width: size.width, height: size.height }}
|
||||
/>
|
||||
)}
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={src}
|
||||
src={displayedImageSrc}
|
||||
onLoad={handleImageLoad}
|
||||
onError={(e) => {
|
||||
console.error("Error loading image", e);
|
||||
setFailedToLoadImage(true);
|
||||
}}
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
className={cn("image-component block rounded-md", {
|
||||
hidden: isShimmerVisible,
|
||||
// hide the image while the background calculations of the image loader are in progress (to avoid flickering) and show the loader until then
|
||||
hidden: showImageLoader,
|
||||
"read-only-image": !editor.isEditable,
|
||||
"blur-sm opacity-80 loading-image": !remoteImageSrc,
|
||||
})}
|
||||
style={{
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
aspectRatio: size.aspectRatio,
|
||||
}}
|
||||
/>
|
||||
<ImageToolbarRoot
|
||||
containerClassName="absolute top-1 right-1 z-20 bg-black/40 rounded opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto transition-opacity"
|
||||
image={{
|
||||
src,
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
}}
|
||||
/>
|
||||
{editor.isEditable && selected && <div className="absolute inset-0 size-full bg-custom-primary-500/30" />}
|
||||
{editor.isEditable && (
|
||||
{showImageUtils && (
|
||||
<ImageToolbarRoot
|
||||
containerClassName={
|
||||
"absolute top-1 right-1 z-20 bg-black/40 rounded opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto transition-opacity"
|
||||
}
|
||||
image={{
|
||||
src: remoteImageSrc,
|
||||
aspectRatio: size.aspectRatio,
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{selected && displayedImageSrc === remoteImageSrc && (
|
||||
<div className="absolute inset-0 size-full bg-custom-primary-500/30" />
|
||||
)}
|
||||
{showImageUtils && (
|
||||
<>
|
||||
<div className="opacity-0 group-hover/image-component:opacity-100 absolute inset-0 border-2 border-custom-primary-100 pointer-events-none rounded-md transition-opacity duration-100 ease-in-out" />
|
||||
<div
|
||||
className="opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto absolute bottom-0 right-0 translate-y-1/2 translate-x-1/2 size-4 rounded-full bg-custom-primary-100 border-2 border-white cursor-nwse-resize transition-opacity duration-100 ease-in-out"
|
||||
className={cn(
|
||||
"absolute inset-0 border-2 border-custom-primary-100 pointer-events-none rounded-md transition-opacity duration-100 ease-in-out",
|
||||
{
|
||||
"opacity-100": isResizing,
|
||||
"opacity-0 group-hover/image-component:opacity-100": !isResizing,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-0 right-0 translate-y-1/2 translate-x-1/2 size-4 rounded-full bg-custom-primary-100 border-2 border-white cursor-nwse-resize transition-opacity duration-100 ease-in-out",
|
||||
{
|
||||
"opacity-100 pointer-events-auto": isResizing,
|
||||
"opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto":
|
||||
!isResizing,
|
||||
}
|
||||
)}
|
||||
onMouseDown={handleResizeStart}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
|
||||
import { Editor, NodeViewWrapper } from "@tiptap/react";
|
||||
// extensions
|
||||
import {
|
||||
CustomImageBlock,
|
||||
CustomImageUploader,
|
||||
UploadEntity,
|
||||
UploadImageExtensionStorage,
|
||||
} from "@/extensions/custom-image";
|
||||
import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image";
|
||||
|
||||
export type CustomImageNodeViewProps = {
|
||||
getPos: () => number;
|
||||
editor: Editor;
|
||||
node: ProsemirrorNode & {
|
||||
attrs: {
|
||||
src: string;
|
||||
width: number | string;
|
||||
height: number | string;
|
||||
};
|
||||
attrs: ImageAttributes;
|
||||
};
|
||||
updateAttributes: (attrs: Record<string, any>) => void;
|
||||
selected: boolean;
|
||||
@@ -26,94 +17,60 @@ export type CustomImageNodeViewProps = {
|
||||
export const CustomImageNode = (props: CustomImageNodeViewProps) => {
|
||||
const { getPos, editor, node, updateAttributes, selected } = props;
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const hasTriggeredFilePickerRef = useRef(false);
|
||||
const [isUploaded, setIsUploaded] = useState(!!node.attrs.src);
|
||||
const [isUploaded, setIsUploaded] = useState(false);
|
||||
const [imageFromFileSystem, setImageFromFileSystem] = useState<string | undefined>(undefined);
|
||||
const [failedToLoadImage, setFailedToLoadImage] = useState(false);
|
||||
|
||||
const id = node.attrs.id as string;
|
||||
const editorStorage = editor.storage.imageComponent as UploadImageExtensionStorage | undefined;
|
||||
|
||||
const getUploadEntity = useCallback(
|
||||
(): UploadEntity | undefined => editorStorage?.fileMap.get(id),
|
||||
[editorStorage, id]
|
||||
);
|
||||
|
||||
const onUpload = useCallback(
|
||||
(url: string) => {
|
||||
if (url) {
|
||||
setIsUploaded(true);
|
||||
// Update the node view's src attribute
|
||||
updateAttributes({ src: url });
|
||||
editorStorage?.fileMap.delete(id);
|
||||
}
|
||||
},
|
||||
[editorStorage?.fileMap, id, updateAttributes]
|
||||
);
|
||||
|
||||
const uploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
try {
|
||||
// @ts-expect-error - TODO: fix typings, and don't remove await from
|
||||
// here for now
|
||||
const url: string = await editor?.commands.uploadImage(file);
|
||||
|
||||
if (!url) {
|
||||
throw new Error("Something went wrong while uploading the image");
|
||||
}
|
||||
onUpload(url);
|
||||
} catch (error) {
|
||||
console.error("Error uploading file:", error);
|
||||
}
|
||||
},
|
||||
[editor.commands, onUpload]
|
||||
);
|
||||
const [editorContainer, setEditorContainer] = useState<HTMLDivElement | null>(null);
|
||||
const imageComponentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const uploadEntity = getUploadEntity();
|
||||
|
||||
if (uploadEntity) {
|
||||
if (uploadEntity.event === "drop" && "file" in uploadEntity) {
|
||||
uploadFile(uploadEntity.file);
|
||||
} else if (uploadEntity.event === "insert" && fileInputRef.current && !hasTriggeredFilePickerRef.current) {
|
||||
const entity = editorStorage?.fileMap.get(id);
|
||||
if (entity && entity.hasOpenedFileInputOnce) return;
|
||||
fileInputRef.current.click();
|
||||
hasTriggeredFilePickerRef.current = true;
|
||||
if (!entity) return;
|
||||
editorStorage?.fileMap.set(id, { ...entity, hasOpenedFileInputOnce: true });
|
||||
}
|
||||
const closestEditorContainer = imageComponentRef.current?.closest(".editor-container");
|
||||
if (!closestEditorContainer) {
|
||||
console.error("Editor container not found");
|
||||
return;
|
||||
}
|
||||
}, [getUploadEntity, uploadFile]);
|
||||
|
||||
setEditorContainer(closestEditorContainer as HTMLDivElement);
|
||||
}, []);
|
||||
|
||||
// the image is already uploaded if the image-component node has src attribute
|
||||
// and we need to remove the blob from our file system
|
||||
useEffect(() => {
|
||||
if (node.attrs.src) {
|
||||
const remoteImageSrc = node.attrs.src;
|
||||
if (remoteImageSrc) {
|
||||
setIsUploaded(true);
|
||||
setImageFromFileSystem(undefined);
|
||||
} else {
|
||||
setIsUploaded(false);
|
||||
}
|
||||
}, [node.attrs.src]);
|
||||
|
||||
const existingFile = React.useMemo(() => {
|
||||
const entity = getUploadEntity();
|
||||
return entity && entity.event === "drop" ? entity.file : undefined;
|
||||
}, [getUploadEntity]);
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<div className="p-0 mx-0 my-2" data-drag-handle>
|
||||
{isUploaded ? (
|
||||
<div className="p-0 mx-0 my-2" data-drag-handle ref={imageComponentRef}>
|
||||
{(isUploaded || imageFromFileSystem) && !failedToLoadImage ? (
|
||||
<CustomImageBlock
|
||||
imageFromFileSystem={imageFromFileSystem}
|
||||
editorContainer={editorContainer}
|
||||
editor={editor}
|
||||
getPos={getPos}
|
||||
node={node}
|
||||
updateAttributes={updateAttributes}
|
||||
setEditorContainer={setEditorContainer}
|
||||
setFailedToLoadImage={setFailedToLoadImage}
|
||||
selected={selected}
|
||||
updateAttributes={updateAttributes}
|
||||
/>
|
||||
) : (
|
||||
<CustomImageUploader
|
||||
onUpload={onUpload}
|
||||
editor={editor}
|
||||
fileInputRef={fileInputRef}
|
||||
existingFile={existingFile}
|
||||
failedToLoadImage={failedToLoadImage}
|
||||
getPos={getPos}
|
||||
loadImageFromFileSystem={setImageFromFileSystem}
|
||||
node={node}
|
||||
setIsUploaded={setIsUploaded}
|
||||
selected={selected}
|
||||
updateAttributes={updateAttributes}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,36 +1,111 @@
|
||||
import { ChangeEvent, useCallback, useEffect, useRef } from "react";
|
||||
import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { ImageIcon } from "lucide-react";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useUploader, useFileUpload, useDropZone } from "@/hooks/use-file-upload";
|
||||
import { useUploader, useDropZone } from "@/hooks/use-file-upload";
|
||||
// plugins
|
||||
import { isFileValid } from "@/plugins/image";
|
||||
|
||||
type RefType = React.RefObject<HTMLInputElement> | ((instance: HTMLInputElement | null) => void);
|
||||
|
||||
const assignRef = (ref: RefType, value: HTMLInputElement | null) => {
|
||||
if (typeof ref === "function") {
|
||||
ref(value);
|
||||
} else if (ref && typeof ref === "object") {
|
||||
(ref as React.MutableRefObject<HTMLInputElement | null>).current = value;
|
||||
}
|
||||
};
|
||||
// extensions
|
||||
import { getImageComponentImageFileMap, ImageAttributes } from "@/extensions/custom-image";
|
||||
|
||||
export const CustomImageUploader = (props: {
|
||||
onUpload: (url: string) => void;
|
||||
failedToLoadImage: boolean;
|
||||
editor: Editor;
|
||||
fileInputRef: RefType;
|
||||
existingFile?: File;
|
||||
selected: boolean;
|
||||
loadImageFromFileSystem: (file: string) => void;
|
||||
setIsUploaded: (isUploaded: boolean) => void;
|
||||
node: ProsemirrorNode & {
|
||||
attrs: ImageAttributes;
|
||||
};
|
||||
updateAttributes: (attrs: Record<string, any>) => void;
|
||||
getPos: () => number;
|
||||
}) => {
|
||||
const { selected, onUpload, editor, fileInputRef, existingFile } = props;
|
||||
const { loading, uploadFile } = useUploader({ onUpload, editor });
|
||||
const { handleUploadClick, ref: internalRef } = useFileUpload();
|
||||
const {
|
||||
selected,
|
||||
failedToLoadImage,
|
||||
editor,
|
||||
loadImageFromFileSystem,
|
||||
node,
|
||||
setIsUploaded,
|
||||
updateAttributes,
|
||||
getPos,
|
||||
} = props;
|
||||
// ref
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const hasTriggeredFilePickerRef = useRef(false);
|
||||
const imageEntityId = node.attrs.id;
|
||||
|
||||
const imageComponentImageFileMap = useMemo(() => getImageComponentImageFileMap(editor), [editor]);
|
||||
|
||||
const onUpload = useCallback(
|
||||
(url: string) => {
|
||||
if (url) {
|
||||
setIsUploaded(true);
|
||||
// Update the node view's src attribute post upload
|
||||
updateAttributes({ src: url });
|
||||
imageComponentImageFileMap?.delete(imageEntityId);
|
||||
|
||||
const pos = getPos();
|
||||
// get current node
|
||||
const getCurrentSelection = editor.state.selection;
|
||||
const currentNode = editor.state.doc.nodeAt(getCurrentSelection.from);
|
||||
|
||||
// only if the cursor is at the current image component, manipulate
|
||||
// the cursor position
|
||||
if (currentNode && currentNode.type.name === "imageComponent" && currentNode.attrs.src === url) {
|
||||
// control cursor position after upload
|
||||
const nextNode = editor.state.doc.nodeAt(pos + 1);
|
||||
|
||||
if (nextNode && nextNode.type.name === "paragraph") {
|
||||
// If there is a paragraph node after the image component, move the focus to the next node
|
||||
editor.commands.setTextSelection(pos + 1);
|
||||
} else {
|
||||
// create a new paragraph after the image component post upload
|
||||
editor.commands.createParagraphNear();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[imageComponentImageFileMap, imageEntityId, updateAttributes, getPos]
|
||||
);
|
||||
// hooks
|
||||
const { uploading: isImageBeingUploaded, uploadFile } = useUploader({ onUpload, editor, loadImageFromFileSystem });
|
||||
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ uploader: uploadFile });
|
||||
|
||||
const localRef = useRef<HTMLInputElement | null>(null);
|
||||
// the meta data of the image component
|
||||
const meta = useMemo(
|
||||
() => imageComponentImageFileMap?.get(imageEntityId),
|
||||
[imageComponentImageFileMap, imageEntityId]
|
||||
);
|
||||
|
||||
// if the image component is dropped, we check if it has an existing file
|
||||
const existingFile = useMemo(() => (meta && meta.event === "drop" ? meta.file : undefined), [meta]);
|
||||
|
||||
// after the image component is mounted we start the upload process based on
|
||||
// it's uploaded
|
||||
useEffect(() => {
|
||||
if (meta) {
|
||||
if (meta.event === "drop" && "file" in meta) {
|
||||
uploadFile(meta.file);
|
||||
} else if (meta.event === "insert" && fileInputRef.current && !hasTriggeredFilePickerRef.current) {
|
||||
if (meta.hasOpenedFileInputOnce) return;
|
||||
fileInputRef.current.click();
|
||||
hasTriggeredFilePickerRef.current = true;
|
||||
imageComponentImageFileMap?.set(imageEntityId, { ...meta, hasOpenedFileInputOnce: true });
|
||||
}
|
||||
}
|
||||
}, [meta, uploadFile, imageComponentImageFileMap]);
|
||||
|
||||
// check if the image is dropped and set the local image as the existing file
|
||||
useEffect(() => {
|
||||
if (existingFile) {
|
||||
uploadFile(existingFile);
|
||||
}
|
||||
}, [existingFile, uploadFile]);
|
||||
|
||||
const onFileChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -44,13 +119,22 @@ export const CustomImageUploader = (props: {
|
||||
[uploadFile]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// no need to validate as the file is already validated before the drop onto
|
||||
// the editor
|
||||
if (existingFile) {
|
||||
uploadFile(existingFile);
|
||||
const getDisplayMessage = useCallback(() => {
|
||||
const isUploading = isImageBeingUploaded || existingFile;
|
||||
if (failedToLoadImage) {
|
||||
return "Error loading image";
|
||||
}
|
||||
}, [existingFile, uploadFile]);
|
||||
|
||||
if (isUploading) {
|
||||
return "Uploading...";
|
||||
}
|
||||
|
||||
if (draggedInside) {
|
||||
return "Drop image here";
|
||||
}
|
||||
|
||||
return "Add an image";
|
||||
}, [draggedInside, failedToLoadImage, existingFile, isImageBeingUploaded]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -58,28 +142,27 @@ export const CustomImageUploader = (props: {
|
||||
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 hover:text-custom-text-200 bg-custom-background-90 hover:bg-custom-background-80 border border-dashed border-custom-border-300 cursor-pointer transition-all duration-200 ease-in-out",
|
||||
{
|
||||
"bg-custom-background-80 text-custom-text-200": draggedInside,
|
||||
},
|
||||
{
|
||||
"text-custom-primary-200 bg-custom-primary-100/10": selected,
|
||||
"text-custom-primary-200 bg-custom-primary-100/10 hover:bg-custom-primary-100/10 hover:text-custom-primary-200 border-custom-primary-200/10":
|
||||
selected,
|
||||
"text-red-500 cursor-default hover:text-red-500": failedToLoadImage,
|
||||
"bg-red-500/10 hover:bg-red-500/10": failedToLoadImage && selected,
|
||||
}
|
||||
)}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
contentEditable={false}
|
||||
onClick={handleUploadClick}
|
||||
onClick={() => {
|
||||
if (!failedToLoadImage) {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ImageIcon className="size-4" />
|
||||
<div className="text-base font-medium">
|
||||
{loading ? "Uploading..." : draggedInside ? "Drop image here" : existingFile ? "Uploading..." : "Add an image"}
|
||||
</div>
|
||||
<div className="text-base font-medium">{getDisplayMessage()}</div>
|
||||
<input
|
||||
className="size-0 overflow-hidden"
|
||||
ref={(element) => {
|
||||
localRef.current = element;
|
||||
assignRef(fileInputRef, element);
|
||||
assignRef(internalRef as RefType, element);
|
||||
}}
|
||||
ref={fileInputRef}
|
||||
hidden
|
||||
type="file"
|
||||
accept=".jpg,.jpeg,.png,.webp"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ExternalLink, Maximize, Minus, Plus, X } from "lucide-react";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
@@ -8,6 +8,7 @@ type Props = {
|
||||
src: string;
|
||||
height: string;
|
||||
width: string;
|
||||
aspectRatio: number;
|
||||
};
|
||||
isOpen: boolean;
|
||||
toggleFullScreenMode: (val: boolean) => void;
|
||||
@@ -17,13 +18,13 @@ const MAGNIFICATION_VALUES = [0.5, 0.75, 1, 1.5, 1.75, 2];
|
||||
|
||||
export const ImageFullScreenAction: React.FC<Props> = (props) => {
|
||||
const { image, isOpen: isFullScreenEnabled, toggleFullScreenMode } = props;
|
||||
const { height, src, width } = image;
|
||||
const { src, width, aspectRatio } = image;
|
||||
// states
|
||||
const [magnification, setMagnification] = useState(1);
|
||||
// refs
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
// derived values
|
||||
const widthInNumber = useMemo(() => Number(width?.replace("px", "")), [width]);
|
||||
const heightInNumber = useMemo(() => Number(height?.replace("px", "")), [height]);
|
||||
const aspectRatio = useMemo(() => widthInNumber / heightInNumber, [heightInNumber, widthInNumber]);
|
||||
// close handler
|
||||
const handleClose = useCallback(() => {
|
||||
toggleFullScreenMode(false);
|
||||
@@ -55,25 +56,35 @@ export const ImageFullScreenAction: React.FC<Props> = (props) => {
|
||||
// keydown handler
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") handleClose();
|
||||
if (e.key === "+" || e.key === "=") handleIncreaseMagnification();
|
||||
if (e.key === "-") handleDecreaseMagnification();
|
||||
if (e.key === "Escape" || e.key === "+" || e.key === "=" || e.key === "-") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === "Escape") handleClose();
|
||||
if (e.key === "+" || e.key === "=") handleIncreaseMagnification();
|
||||
if (e.key === "-") handleDecreaseMagnification();
|
||||
}
|
||||
},
|
||||
[handleClose, handleDecreaseMagnification, handleIncreaseMagnification]
|
||||
);
|
||||
// click outside handler
|
||||
const handleClickOutside = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
if (modalRef.current && e.target === modalRef.current) {
|
||||
handleClose();
|
||||
}
|
||||
},
|
||||
[handleClose]
|
||||
);
|
||||
// register keydown listener
|
||||
useEffect(() => {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
if (isFullScreenEnabled) {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
if (!isFullScreenEnabled) {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [handleKeyDown, isFullScreenEnabled]);
|
||||
|
||||
return (
|
||||
@@ -86,7 +97,7 @@ export const ImageFullScreenAction: React.FC<Props> = (props) => {
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="relative size-full grid place-items-center">
|
||||
<div ref={modalRef} onClick={handleClickOutside} className="relative size-full grid place-items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
@@ -103,7 +114,7 @@ export const ImageFullScreenAction: React.FC<Props> = (props) => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="fixed bottom-10 left-1/2 -translate-x-1/2 flex items-center justify-center gap-1 rounded-md border border-white/20 py-2 divide-x divide-white/20">
|
||||
<div className="fixed bottom-10 left-1/2 -translate-x-1/2 flex items-center justify-center gap-1 rounded-md border border-white/20 py-2 divide-x divide-white/20 bg-black">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -10,6 +10,7 @@ type Props = {
|
||||
src: string;
|
||||
height: string;
|
||||
width: string;
|
||||
aspectRatio: number;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import { Editor, mergeAttributes } from "@tiptap/core";
|
||||
import { Image } from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
@@ -11,15 +11,24 @@ import { TFileHandler } from "@/types";
|
||||
// helpers
|
||||
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
||||
|
||||
export type InsertImageComponentProps = {
|
||||
file?: File;
|
||||
pos?: number;
|
||||
event: "insert" | "drop";
|
||||
};
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
imageComponent: {
|
||||
setImageUpload: ({ file, pos, event }: { file?: File; pos?: number; event: "insert" | "drop" }) => ReturnType;
|
||||
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
|
||||
uploadImage: (file: File) => () => Promise<string> | undefined;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const getImageComponentImageFileMap = (editor: Editor) =>
|
||||
(editor.storage.imageComponent as UploadImageExtensionStorage | undefined)?.fileMap;
|
||||
|
||||
export interface UploadImageExtensionStorage {
|
||||
fileMap: Map<string, UploadEntity>;
|
||||
}
|
||||
@@ -51,6 +60,9 @@ export const CustomImageExtension = (props: TFileHandler) => {
|
||||
["id"]: {
|
||||
default: null,
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -101,12 +113,13 @@ export const CustomImageExtension = (props: TFileHandler) => {
|
||||
return {
|
||||
fileMap: new Map(),
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
uploadInProgress: false,
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setImageUpload:
|
||||
insertImageComponent:
|
||||
(props: { file?: File; pos?: number; event: "insert" | "drop" }) =>
|
||||
({ commands }) => {
|
||||
// Early return if there's an invalid file being dropped
|
||||
@@ -117,15 +130,21 @@ export const CustomImageExtension = (props: TFileHandler) => {
|
||||
// generate a unique id for the image to keep track of dropped
|
||||
// files' file data
|
||||
const fileId = uuidv4();
|
||||
if (props?.event === "drop" && props.file) {
|
||||
(this.editor.storage.imageComponent as UploadImageExtensionStorage).fileMap.set(fileId, {
|
||||
file: props.file,
|
||||
event: props.event,
|
||||
});
|
||||
} else if (props.event === "insert") {
|
||||
(this.editor.storage.imageComponent as UploadImageExtensionStorage).fileMap.set(fileId, {
|
||||
event: props.event,
|
||||
});
|
||||
|
||||
const imageComponentImageFileMap = getImageComponentImageFileMap(this.editor);
|
||||
|
||||
if (imageComponentImageFileMap) {
|
||||
if (props?.event === "drop" && props.file) {
|
||||
imageComponentImageFileMap.set(fileId, {
|
||||
file: props.file,
|
||||
event: props.event,
|
||||
});
|
||||
} else if (props.event === "insert") {
|
||||
imageComponentImageFileMap.set(fileId, {
|
||||
event: props.event,
|
||||
hasOpenedFileInputOnce: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const attributes = {
|
||||
|
||||
@@ -27,6 +27,9 @@ export const CustomReadOnlyImageExtension = () =>
|
||||
["id"]: {
|
||||
default: null,
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Extension, Editor } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { EditorView } from "@tiptap/pm/view";
|
||||
|
||||
@@ -8,6 +8,7 @@ export const DropHandlerExtension = () =>
|
||||
priority: 1000,
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const editor = this.editor;
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("drop-handler-plugin"),
|
||||
@@ -20,15 +21,9 @@ export const DropHandlerExtension = () =>
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
const pos = view.state.selection.from;
|
||||
imageFiles.forEach((file, index) => {
|
||||
this.editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setImageUpload({ file, pos: pos + index, event: "drop" })
|
||||
.run();
|
||||
});
|
||||
return true;
|
||||
insertImages({ editor, files: imageFiles, initialPos: pos, event: "drop" });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
@@ -45,15 +40,8 @@ export const DropHandlerExtension = () =>
|
||||
});
|
||||
|
||||
if (coordinates) {
|
||||
imageFiles.forEach((file, index) => {
|
||||
setTimeout(() => {
|
||||
this.editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setImageUpload({ file, pos: coordinates.pos + index, event: "drop" })
|
||||
.run();
|
||||
}, index * 100); // Slight delay between insertions
|
||||
});
|
||||
const pos = coordinates.pos;
|
||||
insertImages({ editor, files: imageFiles, initialPos: pos, event: "drop" });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -65,3 +53,40 @@ export const DropHandlerExtension = () =>
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
const insertImages = async ({
|
||||
editor,
|
||||
files,
|
||||
initialPos,
|
||||
event,
|
||||
}: {
|
||||
editor: Editor;
|
||||
files: File[];
|
||||
initialPos: number;
|
||||
event: "insert" | "drop";
|
||||
}) => {
|
||||
let pos = initialPos;
|
||||
|
||||
for (const file of files) {
|
||||
// safe insertion
|
||||
const docSize = editor.state.doc.content.size;
|
||||
pos = Math.min(pos, docSize);
|
||||
|
||||
// Check if the position has a non-empty node
|
||||
const nodeAtPos = editor.state.doc.nodeAt(pos);
|
||||
if (nodeAtPos && nodeAtPos.content.size > 0) {
|
||||
// Move to the end of the current node
|
||||
pos += nodeAtPos.nodeSize;
|
||||
}
|
||||
|
||||
try {
|
||||
// Insert the image at the current position
|
||||
editor.commands.insertImageComponent({ file, pos, event });
|
||||
} catch (error) {
|
||||
console.error(`Error while ${event}ing image:`, error);
|
||||
}
|
||||
|
||||
// Move to the next position
|
||||
pos += 1;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -149,7 +149,7 @@ export const CoreEditorExtensions = ({
|
||||
placeholder: ({ editor, node }) => {
|
||||
if (node.type.name === "heading") return `Heading ${node.attrs.level}`;
|
||||
|
||||
// if (editor.storage.image.uploadInProgress) return "";
|
||||
if (editor.storage.imageComponent.uploadInProgress) return "";
|
||||
|
||||
const shouldHidePlaceholder =
|
||||
editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image");
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { Image } from "@tiptap/extension-image";
|
||||
// extensions
|
||||
import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions";
|
||||
import { UploadImageExtensionStorage } from "@/extensions";
|
||||
|
||||
export const CustomImageComponentWithoutProps = () =>
|
||||
Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
|
||||
@@ -27,6 +26,9 @@ export const CustomImageComponentWithoutProps = () =>
|
||||
["id"]: {
|
||||
default: null,
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -48,10 +50,6 @@ export const CustomImageComponentWithoutProps = () =>
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomImageNode);
|
||||
},
|
||||
});
|
||||
|
||||
export default CustomImageComponentWithoutProps;
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import ImageExt from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// extensions
|
||||
import { CustomImageNode } from "@/extensions";
|
||||
|
||||
export const ImageExtensionWithoutProps = () =>
|
||||
ImageExt.extend({
|
||||
@@ -16,8 +13,4 @@ export const ImageExtensionWithoutProps = () =>
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomImageNode);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
toggleHeadingFour,
|
||||
toggleHeadingFive,
|
||||
toggleHeadingSix,
|
||||
insertImage,
|
||||
} from "@/helpers/editor-commands";
|
||||
// types
|
||||
import { CommandProps, ISlashCommandItem } from "@/types";
|
||||
@@ -226,9 +227,7 @@ const getSuggestionItems =
|
||||
icon: <ImageIcon className="size-3.5" />,
|
||||
description: "Insert an image",
|
||||
searchTerms: ["img", "photo", "picture", "media", "upload"],
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).setImageUpload({ event: "insert" }).run();
|
||||
},
|
||||
command: ({ editor, range }: CommandProps) => insertImage({ editor, event: "insert", range }),
|
||||
},
|
||||
{
|
||||
key: "divider",
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { Editor, Range } from "@tiptap/core";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
// extensions
|
||||
import { replaceCodeWithText } from "@/extensions/code/utils/replace-code-block-with-text";
|
||||
// helpers
|
||||
import { findTableAncestor } from "@/helpers/common";
|
||||
// plugins
|
||||
import { startImageUpload } from "@/plugins/image";
|
||||
// types
|
||||
import { UploadImage } from "@/types";
|
||||
import { InsertImageComponentProps } from "@/extensions";
|
||||
|
||||
export const setText = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).clearNodes().run();
|
||||
@@ -129,6 +126,27 @@ export const insertTableCommand = (editor: Editor, range?: Range) => {
|
||||
else editor.chain().focus().clearNodes().insertTable({ rows: 3, cols: 3 }).run();
|
||||
};
|
||||
|
||||
export const insertImage = ({
|
||||
editor,
|
||||
event,
|
||||
pos,
|
||||
file,
|
||||
range,
|
||||
}: {
|
||||
editor: Editor;
|
||||
event: "insert" | "drop";
|
||||
pos?: number | null;
|
||||
file?: File;
|
||||
range?: Range;
|
||||
}) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).run();
|
||||
|
||||
const imageOptions: InsertImageComponentProps = { event };
|
||||
if (pos) imageOptions.pos = pos;
|
||||
if (file) imageOptions.file = file;
|
||||
return editor?.chain().focus().insertImageComponent(imageOptions).run();
|
||||
};
|
||||
|
||||
export const unsetLinkEditor = (editor: Editor) => {
|
||||
editor.chain().focus().unsetLink().run();
|
||||
};
|
||||
@@ -136,23 +154,3 @@ export const unsetLinkEditor = (editor: Editor) => {
|
||||
export const setLinkEditor = (editor: Editor, url: string) => {
|
||||
editor.chain().focus().setLink({ href: url }).run();
|
||||
};
|
||||
|
||||
export const insertImageCommand = (
|
||||
editor: Editor,
|
||||
uploadFile: UploadImage,
|
||||
savedSelection?: Selection | null,
|
||||
range?: Range
|
||||
) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).run();
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".jpeg, .jpg, .png, .webp";
|
||||
input.onchange = async () => {
|
||||
if (input.files?.length) {
|
||||
const file = input.files[0];
|
||||
const pos = savedSelection?.anchor ?? editor.view.state.selection.from;
|
||||
startImageUpload(editor, file, editor.view, pos, uploadFile);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
@@ -60,6 +60,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
value,
|
||||
} = props;
|
||||
// states
|
||||
|
||||
const [savedSelection, setSavedSelection] = useState<Selection | null>(null);
|
||||
// refs
|
||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||
@@ -106,7 +107,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
// value is null when intentionally passed where syncing is not yet
|
||||
// supported and value is undefined when the data from swr is not populated
|
||||
if (value === null || value === undefined) return;
|
||||
if (editor && !editor.isDestroyed && !editor.storage.image.uploadInProgress) {
|
||||
if (editor && !editor.isDestroyed && !editor.storage.imageComponent.uploadInProgress) {
|
||||
try {
|
||||
editor.commands.setContent(value, false, { preserveWhitespace: "full" });
|
||||
const currentSavedSelection = savedSelectionRef.current;
|
||||
@@ -203,7 +204,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
if (!editorRef.current) return;
|
||||
scrollSummary(editorRef.current, marking);
|
||||
},
|
||||
isEditorReadyToDiscard: () => editorRef.current?.storage.image.uploadInProgress === false,
|
||||
isEditorReadyToDiscard: () => editorRef.current?.storage.imageComponent.uploadInProgress === false,
|
||||
setFocusAtPosition: (position: number) => {
|
||||
if (!editorRef.current || editorRef.current.isDestroyed) {
|
||||
console.error("Editor reference is not available or has been destroyed.");
|
||||
|
||||
@@ -1,17 +1,48 @@
|
||||
import { DragEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { DragEvent, useCallback, useEffect, useState } from "react";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { isFileValid } from "@/plugins/image";
|
||||
|
||||
export const useUploader = ({ onUpload, editor }: { onUpload: (url: string) => void; editor: Editor }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
export const useUploader = ({
|
||||
onUpload,
|
||||
editor,
|
||||
loadImageFromFileSystem,
|
||||
}: {
|
||||
onUpload: (url: string) => void;
|
||||
editor: Editor;
|
||||
loadImageFromFileSystem: (file: string) => void;
|
||||
}) => {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const uploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
setLoading(true);
|
||||
const setImageUploadInProgress = (isUploading: boolean) => {
|
||||
editor.storage.imageComponent.uploadInProgress = isUploading;
|
||||
};
|
||||
setImageUploadInProgress(true);
|
||||
setUploading(true);
|
||||
const fileNameTrimmed = trimFileName(file.name);
|
||||
const fileWithTrimmedName = new File([file], fileNameTrimmed, { type: file.type });
|
||||
const isValid = isFileValid(fileWithTrimmedName);
|
||||
if (!isValid) {
|
||||
setImageUploadInProgress(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (reader.result) {
|
||||
loadImageFromFileSystem(reader.result as string);
|
||||
} else {
|
||||
console.error("Failed to read the file: reader.result is null");
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
console.error("Error reading file");
|
||||
};
|
||||
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(file);
|
||||
const url: string = await editor?.commands.uploadImage(fileWithTrimmedName);
|
||||
|
||||
if (!url) {
|
||||
throw new Error("Something went wrong while uploading the image");
|
||||
@@ -21,24 +52,17 @@ export const useUploader = ({ onUpload, editor }: { onUpload: (url: string) => v
|
||||
console.log(errPayload);
|
||||
const error = errPayload?.response?.data?.error || "Something went wrong";
|
||||
console.error(error);
|
||||
} finally {
|
||||
setImageUploadInProgress(false);
|
||||
setUploading(false);
|
||||
}
|
||||
setLoading(false);
|
||||
},
|
||||
[onUpload, editor]
|
||||
[onUpload]
|
||||
);
|
||||
|
||||
return { loading, uploadFile };
|
||||
return { uploading, uploadFile };
|
||||
};
|
||||
|
||||
export const useFileUpload = () => {
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleUploadClick = useCallback(() => {
|
||||
fileInput.current?.click();
|
||||
}, []);
|
||||
|
||||
return { ref: fileInput, handleUploadClick };
|
||||
};
|
||||
export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) => {
|
||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||
const [draggedInside, setDraggedInside] = useState<boolean>(false);
|
||||
@@ -90,10 +114,9 @@ export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) =>
|
||||
const file = filteredFiles.length > 0 ? filteredFiles[0] : undefined;
|
||||
|
||||
if (file) {
|
||||
const isValid = isFileValid(file);
|
||||
if (isValid) {
|
||||
uploader(file);
|
||||
}
|
||||
uploader(file);
|
||||
} else {
|
||||
console.error("No file found");
|
||||
}
|
||||
},
|
||||
[uploader]
|
||||
@@ -109,3 +132,14 @@ export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) =>
|
||||
|
||||
return { isDragging, draggedInside, onDragEnter, onDragLeave, onDrop };
|
||||
};
|
||||
|
||||
function trimFileName(fileName: string, maxLength = 100) {
|
||||
if (fileName.length > maxLength) {
|
||||
const extension = fileName.split(".").pop();
|
||||
const nameWithoutExtension = fileName.slice(0, -(extension?.length ?? 0 + 1));
|
||||
const allowedNameLength = maxLength - (extension?.length ?? 0) - 1; // -1 for the dot
|
||||
return `${nameWithoutExtension.slice(0, allowedNameLength)}.${extension}`;
|
||||
}
|
||||
|
||||
return fileName;
|
||||
}
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { EditorView } from "@tiptap/pm/view";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// plugins
|
||||
import { findPlaceholder, isFileValid, removePlaceholder, uploadKey } from "@/plugins/image";
|
||||
// types
|
||||
import { UploadImage } from "@/types";
|
||||
|
||||
export async function startImageUpload(
|
||||
editor: Editor,
|
||||
file: File,
|
||||
view: EditorView,
|
||||
pos: number | null,
|
||||
uploadFile: UploadImage
|
||||
) {
|
||||
editor.storage.image.uploadInProgress = true;
|
||||
|
||||
if (!isFileValid(file)) {
|
||||
editor.storage.image.uploadInProgress = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const id = uuidv4();
|
||||
|
||||
const tr = view.state.tr;
|
||||
if (!tr.selection.empty) tr.deleteSelection();
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => {
|
||||
tr.setMeta(uploadKey, {
|
||||
add: {
|
||||
id,
|
||||
pos,
|
||||
src: reader.result,
|
||||
},
|
||||
});
|
||||
view.dispatch(tr);
|
||||
};
|
||||
|
||||
// Handle FileReader errors
|
||||
reader.onerror = (error) => {
|
||||
console.error("FileReader error: ", error);
|
||||
removePlaceholder(editor, view, id);
|
||||
return;
|
||||
};
|
||||
|
||||
try {
|
||||
const fileNameTrimmed = trimFileName(file.name);
|
||||
const fileWithTrimmedName = new File([file], fileNameTrimmed, { type: file.type });
|
||||
|
||||
const resolvedPos = view.state.doc.resolve(pos ?? 0);
|
||||
const nodeBefore = resolvedPos.nodeBefore;
|
||||
|
||||
// if the image is at the start of the line i.e. when nodeBefore is null
|
||||
if (nodeBefore === null) {
|
||||
if (pos) {
|
||||
// so that the image is not inserted at the next line, else incase the
|
||||
// image is inserted at any line where there's some content, the
|
||||
// position is kept as it is to be inserted at the next line
|
||||
pos -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
view.focus();
|
||||
|
||||
const src = await uploadAndValidateImage(fileWithTrimmedName, uploadFile);
|
||||
|
||||
if (src == null) {
|
||||
throw new Error("Resolved image URL is undefined.");
|
||||
}
|
||||
|
||||
const { schema } = view.state;
|
||||
pos = findPlaceholder(view.state, id);
|
||||
|
||||
if (pos == null) {
|
||||
editor.storage.image.uploadInProgress = false;
|
||||
return;
|
||||
}
|
||||
const imageSrc = typeof src === "object" ? reader.result : src;
|
||||
|
||||
const node = schema.nodes.image.create({ src: imageSrc });
|
||||
|
||||
if (pos < 0 || pos > view.state.doc.content.size) {
|
||||
throw new Error("Invalid position to insert the image node.");
|
||||
}
|
||||
|
||||
// insert the image node at the position of the placeholder and remove the placeholder
|
||||
const transaction = view.state.tr.insert(pos, node).setMeta(uploadKey, { remove: { id } });
|
||||
|
||||
view.dispatch(transaction);
|
||||
|
||||
editor.storage.image.uploadInProgress = false;
|
||||
} catch (error) {
|
||||
console.error("Error in uploading and inserting image: ", error);
|
||||
removePlaceholder(editor, view, id);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadAndValidateImage(file: File, uploadFile: UploadImage): Promise<string | undefined> {
|
||||
try {
|
||||
const imageUrl = await uploadFile(file);
|
||||
|
||||
if (imageUrl == null) {
|
||||
throw new Error("Image URL is undefined.");
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.src = imageUrl;
|
||||
image.onload = () => {
|
||||
resolve();
|
||||
};
|
||||
image.onerror = (error) => {
|
||||
console.error("Error in loading image: ", error);
|
||||
reject(error);
|
||||
};
|
||||
});
|
||||
|
||||
return imageUrl;
|
||||
} catch (error) {
|
||||
console.error("Error in uploading image: ", error);
|
||||
// throw error to remove the placeholder
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function trimFileName(fileName: string, maxLength = 100) {
|
||||
if (fileName.length > maxLength) {
|
||||
const extension = fileName.split(".").pop();
|
||||
const nameWithoutExtension = fileName.slice(0, -(extension?.length ?? 0 + 1));
|
||||
const allowedNameLength = maxLength - (extension?.length ?? 0) - 1; // -1 for the dot
|
||||
return `${nameWithoutExtension.slice(0, allowedNameLength)}.${extension}`;
|
||||
}
|
||||
|
||||
return fileName;
|
||||
}
|
||||
@@ -2,6 +2,4 @@ export * from "./types";
|
||||
export * from "./utils";
|
||||
export * from "./constants";
|
||||
export * from "./delete-image";
|
||||
export * from "./image-upload-handler";
|
||||
export * from "./restore-image";
|
||||
export * from "./upload-image";
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { Plugin } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
|
||||
// plugins
|
||||
import { removePlaceholder, uploadKey } from "@/plugins/image";
|
||||
|
||||
export const UploadImagesPlugin = (editor: Editor, cancelUploadImage?: () => void) => {
|
||||
let currentView: EditorView | null = null;
|
||||
|
||||
const createPlaceholder = (src: string): HTMLElement => {
|
||||
const placeholder = document.createElement("div");
|
||||
placeholder.setAttribute("class", "img-placeholder");
|
||||
const image = document.createElement("img");
|
||||
image.setAttribute("class", "opacity-60 rounded-lg border border-custom-border-300");
|
||||
image.src = src;
|
||||
placeholder.appendChild(image);
|
||||
|
||||
return placeholder;
|
||||
};
|
||||
|
||||
const createCancelButton = (id: string): HTMLButtonElement => {
|
||||
const cancelButton = document.createElement("button");
|
||||
cancelButton.type = "button";
|
||||
cancelButton.style.position = "absolute";
|
||||
cancelButton.style.right = "3px";
|
||||
cancelButton.style.top = "3px";
|
||||
cancelButton.setAttribute("class", "opacity-90 rounded-lg");
|
||||
|
||||
cancelButton.onclick = () => {
|
||||
if (currentView) {
|
||||
cancelUploadImage?.();
|
||||
removePlaceholder(editor, currentView, id);
|
||||
}
|
||||
};
|
||||
|
||||
// Create an SVG element from the SVG string
|
||||
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-circle"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>`;
|
||||
const parser = new DOMParser();
|
||||
const svgElement = parser.parseFromString(svgString, "image/svg+xml").documentElement;
|
||||
|
||||
cancelButton.appendChild(svgElement);
|
||||
|
||||
return cancelButton;
|
||||
};
|
||||
|
||||
return new Plugin({
|
||||
key: uploadKey,
|
||||
view(editorView) {
|
||||
currentView = editorView;
|
||||
return {
|
||||
destroy() {
|
||||
currentView = null;
|
||||
},
|
||||
};
|
||||
},
|
||||
state: {
|
||||
init() {
|
||||
return DecorationSet.empty;
|
||||
},
|
||||
apply(tr, set) {
|
||||
set = set.map(tr.mapping, tr.doc);
|
||||
const action = tr.getMeta(uploadKey);
|
||||
if (action && action.add) {
|
||||
const { id, pos, src } = action.add;
|
||||
|
||||
const placeholder = createPlaceholder(src);
|
||||
const cancelButton = createCancelButton(id);
|
||||
|
||||
placeholder.appendChild(cancelButton);
|
||||
|
||||
const deco = Decoration.widget(pos, placeholder, {
|
||||
id,
|
||||
});
|
||||
set = set.add(tr.doc, [deco]);
|
||||
} else if (action && action.remove) {
|
||||
set = set.remove(set.find(undefined, undefined, (spec) => spec.id == action.remove.id));
|
||||
}
|
||||
return set;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return this.getState(state);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,2 +1 @@
|
||||
export * from "./placeholder";
|
||||
export * from "./validate-file";
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { EditorState } from "@tiptap/pm/state";
|
||||
import { DecorationSet, EditorView } from "@tiptap/pm/view";
|
||||
// plugins
|
||||
import { uploadKey } from "@/plugins/image";
|
||||
|
||||
export function findPlaceholder(state: EditorState, id: string): number | null {
|
||||
const decos = uploadKey.getState(state) as DecorationSet;
|
||||
const found = decos.find(undefined, undefined, (spec: { id: string }) => spec.id === id);
|
||||
return found.length ? found[0].from : null;
|
||||
}
|
||||
|
||||
export function removePlaceholder(editor: Editor, view: EditorView, id: string) {
|
||||
const removePlaceholderTr = view.state.tr.setMeta(uploadKey, { remove: { id } });
|
||||
view.dispatch(removePlaceholderTr);
|
||||
editor.storage.image.uploadInProgress = false;
|
||||
}
|
||||
@@ -125,7 +125,7 @@
|
||||
margin-top: 0 !important;
|
||||
margin-bottom: 0;
|
||||
|
||||
&:not(.read-only-image) {
|
||||
&:not(.read-only-image):not(.loading-image) {
|
||||
transition: filter 0.1s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./issue-identifier";
|
||||
export * from "./issue-properties-activity";
|
||||
export * from "./issue-type-switcher";
|
||||
export * from "./issue-type-activity";
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
export type TIssueTypeActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined };
|
||||
|
||||
export const IssueTypeActivity: FC<TIssueTypeActivity> = observer(() => <></>);
|
||||
@@ -51,7 +51,7 @@ export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProp
|
||||
suggestions: mentionSuggestions,
|
||||
}}
|
||||
{...rest}
|
||||
containerClassName={cn("relative pl-3", containerClassName)}
|
||||
containerClassName={cn("relative pl-3 pb-3", containerClassName)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -127,7 +127,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
|
||||
disabled={!isEditable}
|
||||
issueOperations={issueOperations}
|
||||
setIsSubmitting={(value) => setIsSubmitting(value)}
|
||||
containerClassName="-ml-3 !mb-6 border-none"
|
||||
containerClassName="-ml-3 border-none"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
// components
|
||||
// plane web components
|
||||
import { IssueTypeActivity } from "@/plane-web/components/issues/issue-details";
|
||||
// local components
|
||||
import {
|
||||
IssueDefaultActivity,
|
||||
IssueNameActivity,
|
||||
@@ -77,6 +79,8 @@ export const IssueActivityItem: FC<TIssueActivityItem> = observer((props) => {
|
||||
return <IssueArchivedAtActivity {...componentDefaultProps} />;
|
||||
case "inbox":
|
||||
return <IssueInboxActivity {...componentDefaultProps} />;
|
||||
case "type":
|
||||
return <IssueTypeActivity {...componentDefaultProps} />;
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
|
||||
disabled={!isEditable}
|
||||
issueOperations={issueOperations}
|
||||
setIsSubmitting={(value) => setIsSubmitting(value)}
|
||||
containerClassName="-ml-3 !mb-6 border-none"
|
||||
containerClassName="-ml-3 border-none"
|
||||
/>
|
||||
{/* )} */}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer(
|
||||
disabled={disabled}
|
||||
issueOperations={issueOperations}
|
||||
setIsSubmitting={(value) => setIsSubmitting(value)}
|
||||
containerClassName="-ml-3 !mb-6 border-none"
|
||||
containerClassName="-ml-3 border-none"
|
||||
/>
|
||||
|
||||
{currentUser && (
|
||||
|
||||
@@ -6,7 +6,7 @@ import { LayersIcon, LUCIDE_ICONS_LIST } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
|
||||
export type TIssueTypeLogoSize = "sm" | "md" | "lg" | "xl";
|
||||
export type TIssueTypeLogoSize = "xs" | "sm" | "md" | "lg" | "xl";
|
||||
|
||||
type Props = {
|
||||
icon_props: TLogoProps["icon"];
|
||||
@@ -16,6 +16,7 @@ type Props = {
|
||||
};
|
||||
|
||||
const iconSizeMap = {
|
||||
xs: 11,
|
||||
sm: 12.5,
|
||||
md: 14.5,
|
||||
lg: 18,
|
||||
@@ -23,6 +24,7 @@ const iconSizeMap = {
|
||||
};
|
||||
|
||||
const containerSizeMap = {
|
||||
xs: 15.5,
|
||||
sm: 18,
|
||||
md: 21,
|
||||
lg: 25.5,
|
||||
|
||||
@@ -18,18 +18,16 @@ export const IssueTypeDropdown = observer((props: TIssueTypeDropdownProps) => {
|
||||
// store hooks
|
||||
const { loader: issueTypesLoader, getProjectIssueTypes } = useIssueTypes();
|
||||
// derived values
|
||||
const issueTypes = getProjectIssueTypes(projectId, true);
|
||||
const allIssueTypes = getProjectIssueTypes(projectId, false);
|
||||
const activeIssueTypes = getProjectIssueTypes(projectId, true);
|
||||
|
||||
// Can be used with CustomSearchSelect as well
|
||||
const issueTypeOptions = Object.entries(issueTypes).map(([issueTypeId, issueTypeDetail]) => ({
|
||||
const issueTypeOptions = Object.entries(activeIssueTypes).map(([issueTypeId, issueTypeDetail]) => ({
|
||||
value: issueTypeId,
|
||||
query: issueTypeDetail.name ?? "",
|
||||
content: (
|
||||
<div className="flex w-full gap-2 items-center">
|
||||
<IssueTypeLogo
|
||||
icon_props={issueTypeDetail?.logo_props?.icon}
|
||||
isDefault={issueTypeDetail?.is_default}
|
||||
/>
|
||||
<IssueTypeLogo icon_props={issueTypeDetail?.logo_props?.icon} isDefault={issueTypeDetail?.is_default} />
|
||||
<div className="text-sm font-medium text-custom-text-200 truncate">{issueTypeDetail.name}</div>
|
||||
</div>
|
||||
),
|
||||
@@ -49,10 +47,10 @@ export const IssueTypeDropdown = observer((props: TIssueTypeDropdownProps) => {
|
||||
label={
|
||||
<div className="flex w-full gap-2 items-center max-w-44">
|
||||
<IssueTypeLogo
|
||||
icon_props={issueTypes[issueTypeId]?.logo_props?.icon}
|
||||
isDefault={issueTypes[issueTypeId]?.is_default}
|
||||
icon_props={allIssueTypes[issueTypeId]?.logo_props?.icon}
|
||||
isDefault={allIssueTypes[issueTypeId]?.is_default}
|
||||
/>
|
||||
<div className="text-sm font-medium text-custom-text-200 truncate">{issueTypes[issueTypeId]?.name}</div>
|
||||
<div className="text-sm font-medium text-custom-text-200 truncate">{allIssueTypes[issueTypeId]?.name}</div>
|
||||
</div>
|
||||
}
|
||||
options={issueTypeOptions}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./issue-identifier";
|
||||
export * from "./issue-properties-activity";
|
||||
export * from "./issue-type-switcher";
|
||||
export * from "./issue-type-activity";
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// icons
|
||||
import { ArrowRightLeft } from "lucide-react";
|
||||
// ce imports
|
||||
import { IssueTypeActivity as BaseIssueTypeActivity, TIssueTypeActivity } from "@/ce/components/issues";
|
||||
// components
|
||||
import {
|
||||
IssueActivityBlockComponent,
|
||||
IssueLink,
|
||||
} from "@/components/issues/issue-detail/issue-activity/activity/actions";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
// plane web components
|
||||
import { IssueTypeLogo } from "@/plane-web/components/issue-types";
|
||||
// plane web hooks
|
||||
import { useIssueTypes } from "@/plane-web/hooks/store";
|
||||
|
||||
type TIssueTypeDetail = {
|
||||
issueTypeId: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const IssueTypeDetail: FC<TIssueTypeDetail> = observer((props) => {
|
||||
const { issueTypeId, className = "" } = props;
|
||||
// hooks
|
||||
const { getIssueTypeById } = useIssueTypes();
|
||||
// derived values
|
||||
const issueTypeDetail = getIssueTypeById(issueTypeId);
|
||||
|
||||
return (
|
||||
<span className={cn("inline-flex gap-1 items-center font-medium text-custom-text-100", className)}>
|
||||
<IssueTypeLogo icon_props={issueTypeDetail?.logo_props?.icon} size="xs" isDefault={issueTypeDetail?.is_default} />
|
||||
{issueTypeDetail?.name}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
export const IssueTypeActivity: FC<TIssueTypeActivity> = observer((props) => {
|
||||
const { activityId, showIssue = false, ends } = props;
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// hooks
|
||||
const {
|
||||
activity: { getActivityById },
|
||||
} = useIssueDetail();
|
||||
const { isIssueTypeEnabledForProject } = useIssueTypes();
|
||||
// derived values
|
||||
const activity = getActivityById(activityId);
|
||||
if (!activity) return <></>;
|
||||
|
||||
const isIssueTypeDisplayEnabled =
|
||||
workspaceSlug && activity?.project
|
||||
? isIssueTypeEnabledForProject(workspaceSlug?.toString(), activity?.project, "ISSUE_TYPE_DISPLAY")
|
||||
: false;
|
||||
|
||||
if (!isIssueTypeDisplayEnabled) return <BaseIssueTypeActivity {...props} />;
|
||||
|
||||
return (
|
||||
<IssueActivityBlockComponent
|
||||
icon={<ArrowRightLeft className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-200" />}
|
||||
activityId={activityId}
|
||||
ends={ends}
|
||||
>
|
||||
<span className="inline-flex items-center">
|
||||
changed issue type to{" "}
|
||||
{activity.new_identifier && <IssueTypeDetail issueTypeId={activity.new_identifier} className="px-1" />}
|
||||
from {activity.old_identifier && <IssueTypeDetail issueTypeId={activity.old_identifier} className="pl-1" />}
|
||||
{showIssue ? ` for ` : ``}
|
||||
{showIssue && <IssueLink activityId={activityId} />}.
|
||||
</span>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
@@ -28,6 +28,7 @@ export const IssueTypeSwitcher: React.FC<TIssueTypeSwitcherProps> = observer((pr
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
toggleCreateIssueModal,
|
||||
fetchActivities,
|
||||
} = useIssueDetail();
|
||||
const { isIssueTypeEnabledForProject } = useIssueTypes();
|
||||
// derived values
|
||||
@@ -54,6 +55,11 @@ export const IssueTypeSwitcher: React.FC<TIssueTypeSwitcherProps> = observer((pr
|
||||
<>
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={isCreateUpdateIssueModalOpen}
|
||||
onSubmit={async () => {
|
||||
if (workspaceSlug && issue.project_id) {
|
||||
await fetchActivities(workspaceSlug.toString(), issue.project_id, issueId);
|
||||
}
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsCreateUpdateIssueModalOpen(false);
|
||||
toggleCreateIssueModal(false);
|
||||
@@ -72,7 +78,7 @@ export const IssueTypeSwitcher: React.FC<TIssueTypeSwitcherProps> = observer((pr
|
||||
{!disabled && (
|
||||
<span className="flex opacity-0 group-hover:opacity-100 transition-opacity duration-300 items-center gap-1 text-xs font-medium text-custom-text-300">
|
||||
<ArrowRightLeft className="w-3 h-3 flex-shrink-0" />
|
||||
Switch Issue Type
|
||||
Switch issue type
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useIssueDetail } from "@/hooks/store";
|
||||
// plane web helpers
|
||||
import { getPropertiesDefaultValues } from "@/plane-web/helpers/issue-properties.helper";
|
||||
// plane web hooks
|
||||
import { useIssueTypes } from "@/plane-web/hooks/store";
|
||||
import { useIssuePropertiesActivity, useIssueTypes } from "@/plane-web/hooks/store";
|
||||
// plane web services
|
||||
import { IssuePropertyValuesService } from "@/plane-web/services/issue-types";
|
||||
// plane web types
|
||||
@@ -44,7 +44,7 @@ export const IssueModalProvider = observer((props: TIssueModalProviderProps) =>
|
||||
getProjectIssueTypes,
|
||||
getProjectDefaultIssueType,
|
||||
} = useIssueTypes();
|
||||
|
||||
const { fetchPropertyActivities } = useIssuePropertiesActivity();
|
||||
// helpers
|
||||
const getIssueTypeIdOnProjectChange = (projectId: string) => {
|
||||
// get active issue types for the project
|
||||
@@ -135,7 +135,10 @@ export const IssueModalProvider = observer((props: TIssueModalProviderProps) =>
|
||||
await issuePropertyValuesService
|
||||
.create(workspaceSlug, projectId, issueId, filteredIssuePropertyValues)
|
||||
.then(() => {
|
||||
// mutate issue property values
|
||||
mutate(`ISSUE_PROPERTY_VALUES_${workspaceSlug}_${projectId}_${issueId}_${isIssueTypeDisplayEnabled}`);
|
||||
// fetch property activities
|
||||
fetchPropertyActivities(workspaceSlug, projectId, issueId);
|
||||
// reset issue property values
|
||||
setIssuePropertyValues({
|
||||
...getPropertiesDefaultValues(issueType?.activeProperties ?? []),
|
||||
|
||||
10
yarn.lock
10
yarn.lock
@@ -4213,7 +4213,7 @@
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react@*", "@types/react@18.2.48", "@types/react@^16.8.0 || ^17.0.0 || ^18.0.0", "@types/react@^18.2.42", "@types/react@^18.2.48", "@types/react@^18.3.5":
|
||||
"@types/react@*", "@types/react@^16.8.0 || ^17.0.0 || ^18.0.0", "@types/react@^18.2.42", "@types/react@^18.2.48":
|
||||
version "18.2.48"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.48.tgz#11df5664642d0bd879c1f58bc1d37205b064e8f1"
|
||||
integrity sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w==
|
||||
@@ -4222,6 +4222,14 @@
|
||||
"@types/scheduler" "*"
|
||||
csstype "^3.0.2"
|
||||
|
||||
"@types/react@^18.3.5":
|
||||
version "18.3.9"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.9.tgz#2cdf5f425ec8a133d67e9e3673909738b783db20"
|
||||
integrity sha512-+BpAVyTpJkNWWSSnaLBk6ePpHLOGJKnEQNbINNovPWzvEUyAe3e+/d494QdEh71RekM/qV7lw6jzf1HGrJyAtQ==
|
||||
dependencies:
|
||||
"@types/prop-types" "*"
|
||||
csstype "^3.0.2"
|
||||
|
||||
"@types/reactcss@*":
|
||||
version "1.2.12"
|
||||
resolved "https://registry.yarnpkg.com/@types/reactcss/-/reactcss-1.2.12.tgz#57f6f046e7aafbe0288689bd96a2d5664378ca7b"
|
||||
|
||||
Reference in New Issue
Block a user