+
+ {!props.editor.isActive("table") && (
+ {
+ setIsNodeSelectorOpen((prev) => !prev);
+ setIsLinkSelectorOpen(false);
+ setIsColorSelectorOpen(false);
+ }}
+ />
+ )}
+
+
+ {!props.editor.isActive("code") && (
+ {
+ setIsLinkSelectorOpen((prev) => !prev);
+ setIsNodeSelectorOpen(false);
+ setIsColorSelectorOpen(false);
+ }}
+ />
+ )}
+
+
+ {!props.editor.isActive("code") && (
+ {
+ setIsColorSelectorOpen((prev) => !prev);
+ setIsNodeSelectorOpen(false);
+ setIsLinkSelectorOpen(false);
+ }}
+ />
+ )}
+
+
{items.map((item) => (
))}
diff --git a/packages/editor/src/core/components/menus/menu-items.ts b/packages/editor/src/core/components/menus/menu-items.ts
index cf10081f1e..5c420832ac 100644
--- a/packages/editor/src/core/components/menus/menu-items.ts
+++ b/packages/editor/src/core/components/menus/menu-items.ts
@@ -20,12 +20,14 @@ import {
Heading6,
CaseSensitive,
LucideIcon,
+ Palette,
} from "lucide-react";
// helpers
import {
insertImage,
insertTableCommand,
setText,
+ toggleBackgroundColor,
toggleBlockquote,
toggleBold,
toggleBulletList,
@@ -40,18 +42,26 @@ import {
toggleOrderedList,
toggleStrike,
toggleTaskList,
+ toggleTextColor,
toggleUnderline,
} from "@/helpers/editor-commands";
// types
-import { TEditorCommands } from "@/types";
+import { TColorEditorCommands, TNonColorEditorCommands } from "@/types";
-export interface EditorMenuItem {
- key: TEditorCommands;
+export type EditorMenuItem = {
name: string;
- isActive: () => boolean;
- command: () => void;
+ command: (...args: any) => void;
icon: LucideIcon;
-}
+} & (
+ | {
+ key: TNonColorEditorCommands;
+ isActive: () => boolean;
+ }
+ | {
+ key: TColorEditorCommands;
+ isActive: (color: string | undefined) => boolean;
+ }
+);
export const TextItem = (editor: Editor): EditorMenuItem => ({
key: "text",
@@ -198,10 +208,25 @@ export const ImageItem = (editor: Editor) =>
icon: ImageIcon,
}) as const;
-export function getEditorMenuItems(editor: Editor | null) {
- if (!editor) {
- return [];
- }
+export const TextColorItem = (editor: Editor): EditorMenuItem => ({
+ key: "text-color",
+ name: "Color",
+ isActive: (color) => editor.isActive("customColor", { color }),
+ command: (color: string) => toggleTextColor(color, editor),
+ icon: Palette,
+});
+
+export const BackgroundColorItem = (editor: Editor): EditorMenuItem => ({
+ key: "background-color",
+ name: "Background color",
+ isActive: (color) => editor.isActive("customColor", { backgroundColor: color }),
+ command: (color: string) => toggleBackgroundColor(color, editor),
+ icon: Palette,
+});
+
+export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem[] => {
+ if (!editor) return [];
+
return [
TextItem(editor),
HeadingOneItem(editor),
@@ -221,5 +246,7 @@ export function getEditorMenuItems(editor: Editor | null) {
QuoteItem(editor),
TableItem(editor),
ImageItem(editor),
+ TextColorItem(editor),
+ BackgroundColorItem(editor),
];
-}
+};
diff --git a/packages/editor/src/core/constants/common.ts b/packages/editor/src/core/constants/common.ts
new file mode 100644
index 0000000000..7f4f7f66f3
--- /dev/null
+++ b/packages/editor/src/core/constants/common.ts
@@ -0,0 +1,61 @@
+export const COLORS_LIST: {
+ key: string;
+ label: string;
+ textColor: string;
+ backgroundColor: string;
+}[] = [
+ {
+ key: "gray",
+ label: "Gray",
+ textColor: "var(--editor-colors-gray-text)",
+ backgroundColor: "var(--editor-colors-gray-background)",
+ },
+ {
+ key: "peach",
+ label: "Peach",
+ textColor: "var(--editor-colors-peach-text)",
+ backgroundColor: "var(--editor-colors-peach-background)",
+ },
+ {
+ key: "pink",
+ label: "Pink",
+ textColor: "var(--editor-colors-pink-text)",
+ backgroundColor: "var(--editor-colors-pink-background)",
+ },
+ {
+ key: "orange",
+ label: "Orange",
+ textColor: "var(--editor-colors-orange-text)",
+ backgroundColor: "var(--editor-colors-orange-background)",
+ },
+ {
+ key: "green",
+ label: "Green",
+ textColor: "var(--editor-colors-green-text)",
+ backgroundColor: "var(--editor-colors-green-background)",
+ },
+ {
+ key: "light-blue",
+ label: "Light blue",
+ textColor: "var(--editor-colors-light-blue-text)",
+ backgroundColor: "var(--editor-colors-light-blue-background)",
+ },
+ {
+ key: "dark-blue",
+ label: "Dark blue",
+ textColor: "var(--editor-colors-dark-blue-text)",
+ backgroundColor: "var(--editor-colors-dark-blue-background)",
+ },
+ {
+ key: "purple",
+ label: "Purple",
+ textColor: "var(--editor-colors-purple-text)",
+ backgroundColor: "var(--editor-colors-purple-background)",
+ },
+ // {
+ // key: "pink-blue-gradient",
+ // label: "Pink blue gradient",
+ // textColor: "var(--editor-colors-pink-blue-gradient-text)",
+ // backgroundColor: "var(--editor-colors-pink-blue-gradient-background)",
+ // },
+];
diff --git a/packages/editor/src/core/extensions/core-without-props.ts b/packages/editor/src/core/extensions/core-without-props.ts
index 1cedd51396..10d4df2026 100644
--- a/packages/editor/src/core/extensions/core-without-props.ts
+++ b/packages/editor/src/core/extensions/core-without-props.ts
@@ -16,6 +16,7 @@ import { IssueWidgetWithoutProps } from "./issue-embed/issue-embed-without-props
import { CustomMentionWithoutProps } from "./mentions/mentions-without-props";
import { CustomQuoteExtension } from "./quote";
import { TableHeader, TableCell, TableRow, Table } from "./table";
+import { CustomColorExtension } from "./custom-color";
export const CoreEditorExtensionsWithoutProps = [
StarterKit.configure({
@@ -83,6 +84,7 @@ export const CoreEditorExtensionsWithoutProps = [
TableCell,
TableRow,
CustomMentionWithoutProps(),
+ CustomColorExtension,
];
export const DocumentEditorExtensionsWithoutProps = [IssueWidgetWithoutProps()];
diff --git a/packages/editor/src/core/extensions/custom-color.ts b/packages/editor/src/core/extensions/custom-color.ts
new file mode 100644
index 0000000000..dc966816c5
--- /dev/null
+++ b/packages/editor/src/core/extensions/custom-color.ts
@@ -0,0 +1,133 @@
+import { Mark, mergeAttributes } from "@tiptap/core";
+// constants
+import { COLORS_LIST } from "@/constants/common";
+
+declare module "@tiptap/core" {
+ interface Commands
{
+ color: {
+ /**
+ * Set the text color
+ * @param {string} color The color to set
+ * @example editor.commands.setTextColor('red')
+ */
+ setTextColor: (color: string) => ReturnType;
+
+ /**
+ * Unset the text color
+ * @example editor.commands.unsetTextColor()
+ */
+ unsetTextColor: () => ReturnType;
+ /**
+ * Set the background color
+ * @param {string} backgroundColor The color to set
+ * @example editor.commands.setBackgroundColor('red')
+ */
+ setBackgroundColor: (backgroundColor: string) => ReturnType;
+
+ /**
+ * Unset the background color
+ * @example editor.commands.unsetBackgroundColorColor()
+ */
+ unsetBackgroundColor: () => ReturnType;
+ };
+ }
+}
+
+export const CustomColorExtension = Mark.create({
+ name: "customColor",
+
+ addOptions() {
+ return {
+ HTMLAttributes: {},
+ };
+ },
+
+ addAttributes() {
+ return {
+ color: {
+ default: null,
+ parseHTML: (element: HTMLElement) => element.getAttribute("data-text-color"),
+ renderHTML: (attributes: { color: string }) => {
+ const { color } = attributes;
+ if (!color) {
+ return {};
+ }
+
+ let elementAttributes: Record = {
+ "data-text-color": color,
+ };
+
+ if (!COLORS_LIST.find((c) => c.key === color)) {
+ elementAttributes = {
+ ...elementAttributes,
+ style: `color: ${color}`,
+ };
+ }
+
+ return elementAttributes;
+ },
+ },
+ backgroundColor: {
+ default: null,
+ parseHTML: (element: HTMLElement) => element.getAttribute("data-background-color"),
+ renderHTML: (attributes: { backgroundColor: string }) => {
+ const { backgroundColor } = attributes;
+ if (!backgroundColor) {
+ return {};
+ }
+
+ let elementAttributes: Record = {
+ "data-background-color": backgroundColor,
+ };
+
+ if (!COLORS_LIST.find((c) => c.key === backgroundColor)) {
+ elementAttributes = {
+ ...elementAttributes,
+ style: `background-color: ${backgroundColor}`,
+ };
+ }
+
+ return elementAttributes;
+ },
+ },
+ };
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: "span",
+ getAttrs: (node) => node.getAttribute("data-text-color") && null,
+ },
+ {
+ tag: "span",
+ getAttrs: (node) => node.getAttribute("data-background-color") && null,
+ },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ["span", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
+ },
+
+ addCommands() {
+ return {
+ setTextColor:
+ (color: string) =>
+ ({ chain }) =>
+ chain().setMark(this.name, { color }).run(),
+ unsetTextColor:
+ () =>
+ ({ chain }) =>
+ chain().setMark(this.name, { color: null }).run(),
+ setBackgroundColor:
+ (backgroundColor: string) =>
+ ({ chain }) =>
+ chain().setMark(this.name, { backgroundColor }).run(),
+ unsetBackgroundColor:
+ () =>
+ ({ chain }) =>
+ chain().setMark(this.name, { backgroundColor: null }).run(),
+ };
+ },
+});
diff --git a/packages/editor/src/core/extensions/custom-image/components/image-block.tsx b/packages/editor/src/core/extensions/custom-image/components/image-block.tsx
index 42b51de5fb..65d9a38433 100644
--- a/packages/editor/src/core/extensions/custom-image/components/image-block.tsx
+++ b/packages/editor/src/core/extensions/custom-image/components/image-block.tsx
@@ -42,6 +42,7 @@ type CustomImageBlockProps = CustomImageNodeViewProps & {
setFailedToLoadImage: (isError: boolean) => void;
editorContainer: HTMLDivElement | null;
setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
+ src: string;
};
export const CustomImageBlock: React.FC = (props) => {
@@ -55,14 +56,15 @@ export const CustomImageBlock: React.FC = (props) => {
getPos,
editor,
editorContainer,
+ src: remoteImageSrc,
setEditorContainer,
} = props;
- const { src: remoteImageSrc, width, height, aspectRatio } = node.attrs;
+ const { width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio } = node.attrs;
// states
const [size, setSize] = useState({
- width: ensurePixelString(width, "35%"),
- height: ensurePixelString(height, "auto"),
- aspectRatio: aspectRatio || 1,
+ width: ensurePixelString(nodeWidth, "35%"),
+ height: ensurePixelString(nodeHeight, "auto"),
+ aspectRatio: nodeAspectRatio || null,
});
const [isResizing, setIsResizing] = useState(false);
const [initialResizeComplete, setInitialResizeComplete] = useState(false);
@@ -70,6 +72,19 @@ export const CustomImageBlock: React.FC = (props) => {
const containerRef = useRef(null);
const containerRect = useRef(null);
const imageRef = useRef(null);
+ const [hasErroredOnFirstLoad, setHasErroredOnFirstLoad] = useState(false);
+ const [hasTriedRestoringImageOnce, setHasTriedRestoringImageOnce] = useState(false);
+
+ const updateAttributesSafely = useCallback(
+ (attributes: Partial, errorMessage: string) => {
+ try {
+ updateAttributes(attributes);
+ } catch (error) {
+ console.error(`${errorMessage}:`, error);
+ }
+ },
+ [updateAttributes]
+ );
const handleImageLoad = useCallback(() => {
const img = imageRef.current;
@@ -91,40 +106,50 @@ export const CustomImageBlock: React.FC = (props) => {
}
setEditorContainer(closestEditorContainer);
- const aspectRatio = img.naturalWidth / img.naturalHeight;
+ const aspectRatioCalculated = img.naturalWidth / img.naturalHeight;
- if (width === "35%") {
+ if (nodeWidth === "35%") {
const editorWidth = closestEditorContainer.clientWidth;
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
- const initialHeight = initialWidth / aspectRatio;
+ const initialHeight = initialWidth / aspectRatioCalculated;
const initialComputedSize = {
width: `${Math.round(initialWidth)}px` satisfies Pixel,
height: `${Math.round(initialHeight)}px` satisfies Pixel,
- aspectRatio: aspectRatio,
+ aspectRatio: aspectRatioCalculated,
};
setSize(initialComputedSize);
- updateAttributes(initialComputedSize);
+ updateAttributesSafely(
+ initialComputedSize,
+ "Failed to update attributes while initializing an image for the first time:"
+ );
} 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;
- });
+ // or if aspectRatioCalculated from the image's width and height doesn't match stored aspectRatio then also we'll update the attrs
+ if (!nodeAspectRatio || nodeAspectRatio !== aspectRatioCalculated) {
+ setSize((prevSize) => {
+ const newSize = { ...prevSize, aspectRatio: aspectRatioCalculated };
+ updateAttributesSafely(
+ newSize,
+ "Failed to update attributes while initializing images with width but no aspect ratio:"
+ );
+ return newSize;
+ });
+ }
}
setInitialResizeComplete(true);
- }, [width, updateAttributes, editorContainer]);
+ }, [nodeWidth, updateAttributes, editorContainer, nodeAspectRatio]);
// for real time resizing
useLayoutEffect(() => {
setSize((prevSize) => ({
...prevSize,
- width: ensurePixelString(width),
- height: ensurePixelString(height),
+ width: ensurePixelString(nodeWidth),
+ height: ensurePixelString(nodeHeight),
+ aspectRatio: nodeAspectRatio,
}));
- }, [width, height]);
+ }, [nodeWidth, nodeHeight, nodeAspectRatio]);
const handleResize = useCallback(
(e: MouseEvent | TouchEvent) => {
@@ -137,12 +162,12 @@ export const CustomImageBlock: React.FC = (props) => {
setSize((prevSize) => ({ ...prevSize, width: `${newWidth}px`, height: `${newHeight}px` }));
},
- [size]
+ [size.aspectRatio]
);
const handleResizeEnd = useCallback(() => {
setIsResizing(false);
- updateAttributes(size);
+ updateAttributesSafely(size, "Failed to update attributes at the end of resizing:");
}, [size, updateAttributes]);
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
@@ -160,11 +185,15 @@ export const CustomImageBlock: React.FC = (props) => {
window.addEventListener("mousemove", handleResize);
window.addEventListener("mouseup", handleResizeEnd);
window.addEventListener("mouseleave", handleResizeEnd);
+ window.addEventListener("touchmove", handleResize);
+ window.addEventListener("touchend", handleResizeEnd);
return () => {
window.removeEventListener("mousemove", handleResize);
window.removeEventListener("mouseup", handleResizeEnd);
window.removeEventListener("mouseleave", handleResizeEnd);
+ window.removeEventListener("touchmove", handleResize);
+ window.removeEventListener("touchend", handleResizeEnd);
};
}
}, [isResizing, handleResize, handleResizeEnd]);
@@ -181,11 +210,13 @@ export const CustomImageBlock: React.FC = (props) => {
// show the image loader if the remote image's src or preview image from filesystem is not set yet (while loading the image post upload) (or)
// if the initial resize (from 35% width and "auto" height attrs to the actual size in px) is not complete
- const showImageLoader = !(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;
+ const showImageLoader = !(remoteImageSrc || imageFromFileSystem) || !initialResizeComplete || hasErroredOnFirstLoad;
+ // show the image utils only if the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)
+ const showImageUtils = remoteImageSrc && initialResizeComplete;
+ // show the image resizer only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)
+ const showImageResizer = 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;
+ const displayedImageSrc = remoteImageSrc || imageFromFileSystem;
return (
= (props) => {
onMouseDown={handleImageMouseDown}
style={{
width: size.width,
- aspectRatio: size.aspectRatio,
+ ...(size.aspectRatio && { aspectRatio: size.aspectRatio }),
}}
>
{showImageLoader && (
@@ -207,9 +238,26 @@ export const CustomImageBlock: React.FC
= (props) => {
ref={imageRef}
src={displayedImageSrc}
onLoad={handleImageLoad}
- onError={(e) => {
- console.error("Error loading image", e);
- setFailedToLoadImage(true);
+ onError={async (e) => {
+ // for old image extension this command doesn't exist or if the image failed to load for the first time
+ if (!editor?.commands.restoreImage || hasTriedRestoringImageOnce) {
+ setFailedToLoadImage(true);
+ return;
+ }
+
+ try {
+ setHasErroredOnFirstLoad(true);
+ // this is a type error from tiptap, don't remove await until it's fixed
+ await editor?.commands.restoreImage?.(node.attrs.src);
+ imageRef.current.src = remoteImageSrc;
+ } catch {
+ // if the image failed to even restore, then show the error state
+ setFailedToLoadImage(true);
+ console.error("Error while loading image", e);
+ } finally {
+ setHasErroredOnFirstLoad(false);
+ setHasTriedRestoringImageOnce(true);
+ }
}}
width={size.width}
className={cn("image-component block rounded-md", {
@@ -220,7 +268,7 @@ export const CustomImageBlock: React.FC = (props) => {
})}
style={{
width: size.width,
- aspectRatio: size.aspectRatio,
+ ...(size.aspectRatio && { aspectRatio: size.aspectRatio }),
}}
/>
{showImageUtils && (
@@ -239,7 +287,7 @@ export const CustomImageBlock: React.FC = (props) => {
{selected && displayedImageSrc === remoteImageSrc && (
)}
- {showImageUtils && (
+ {showImageResizer && (
<>
= (props) => {
}
)}
onMouseDown={handleResizeStart}
+ onTouchStart={handleResizeStart}
/>
>
)}
diff --git a/packages/editor/src/core/extensions/custom-image/components/image-node.tsx b/packages/editor/src/core/extensions/custom-image/components/image-node.tsx
index c37bcd29cd..bdb8280c5b 100644
--- a/packages/editor/src/core/extensions/custom-image/components/image-node.tsx
+++ b/packages/editor/src/core/extensions/custom-image/components/image-node.tsx
@@ -1,21 +1,23 @@
import { useEffect, useRef, useState } from "react";
-import { Node as ProsemirrorNode } from "@tiptap/pm/model";
-import { Editor, NodeViewWrapper } from "@tiptap/react";
+import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
// extensions
import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image";
-export type CustomImageNodeViewProps = {
+export type CustomImageComponentProps = {
getPos: () => number;
editor: Editor;
- node: ProsemirrorNode & {
+ node: NodeViewProps["node"] & {
attrs: ImageAttributes;
};
- updateAttributes: (attrs: Record
) => void;
+ updateAttributes: (attrs: ImageAttributes) => void;
selected: boolean;
};
+export type CustomImageNodeViewProps = NodeViewProps & CustomImageComponentProps;
+
export const CustomImageNode = (props: CustomImageNodeViewProps) => {
const { getPos, editor, node, updateAttributes, selected } = props;
+ const { src: remoteImageSrc } = node.attrs;
const [isUploaded, setIsUploaded] = useState(false);
const [imageFromFileSystem, setImageFromFileSystem] = useState(undefined);
@@ -37,14 +39,13 @@ export const CustomImageNode = (props: CustomImageNodeViewProps) => {
// 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(() => {
- const remoteImageSrc = node.attrs.src;
if (remoteImageSrc) {
setIsUploaded(true);
setImageFromFileSystem(undefined);
} else {
setIsUploaded(false);
}
- }, [node.attrs.src]);
+ }, [remoteImageSrc]);
return (
@@ -54,6 +55,8 @@ export const CustomImageNode = (props: CustomImageNodeViewProps) => {
imageFromFileSystem={imageFromFileSystem}
editorContainer={editorContainer}
editor={editor}
+ // @ts-expect-error function not expected here, but will still work
+ src={editor?.commands?.getImageSource?.(remoteImageSrc)}
getPos={getPos}
node={node}
setEditorContainer={setEditorContainer}
@@ -67,6 +70,7 @@ export const CustomImageNode = (props: CustomImageNodeViewProps) => {
failedToLoadImage={failedToLoadImage}
getPos={getPos}
loadImageFromFileSystem={setImageFromFileSystem}
+ maxFileSize={editor.storage.imageComponent.maxFileSize}
node={node}
setIsUploaded={setIsUploaded}
selected={selected}
diff --git a/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx b/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx
index 89cf36ca52..36f1361ee8 100644
--- a/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx
+++ b/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx
@@ -1,44 +1,36 @@
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, useDropZone } from "@/hooks/use-file-upload";
-// plugins
-import { isFileValid } from "@/plugins/image";
+import { useUploader, useDropZone, uploadFirstImageAndInsertRemaining } from "@/hooks/use-file-upload";
// extensions
-import { getImageComponentImageFileMap, ImageAttributes } from "@/extensions/custom-image";
+import { type CustomImageComponentProps, getImageComponentImageFileMap } from "@/extensions/custom-image";
-export const CustomImageUploader = (props: {
- failedToLoadImage: boolean;
- editor: Editor;
- selected: boolean;
+type CustomImageUploaderProps = CustomImageComponentProps & {
+ maxFileSize: number;
loadImageFromFileSystem: (file: string) => void;
+ failedToLoadImage: boolean;
setIsUploaded: (isUploaded: boolean) => void;
- node: ProsemirrorNode & {
- attrs: ImageAttributes;
- };
- updateAttributes: (attrs: Record) => void;
- getPos: () => number;
-}) => {
+};
+
+export const CustomImageUploader = (props: CustomImageUploaderProps) => {
const {
- selected,
- failedToLoadImage,
editor,
+ failedToLoadImage,
+ getPos,
loadImageFromFileSystem,
+ maxFileSize,
node,
+ selected,
setIsUploaded,
updateAttributes,
- getPos,
} = props;
- // ref
+ // refs
const fileInputRef = useRef(null);
-
const hasTriggeredFilePickerRef = useRef(false);
- const imageEntityId = node.attrs.id;
-
+ const { id: imageEntityId } = node.attrs;
+ // derived values
const imageComponentImageFileMap = useMemo(() => getImageComponentImageFileMap(editor), [editor]);
const onUpload = useCallback(
@@ -73,8 +65,18 @@ export const CustomImageUploader = (props: {
[imageComponentImageFileMap, imageEntityId, updateAttributes, getPos]
);
// hooks
- const { uploading: isImageBeingUploaded, uploadFile } = useUploader({ onUpload, editor, loadImageFromFileSystem });
- const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ uploader: uploadFile });
+ const { uploading: isImageBeingUploaded, uploadFile } = useUploader({
+ editor,
+ loadImageFromFileSystem,
+ maxFileSize,
+ onUpload,
+ });
+ const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({
+ editor,
+ maxFileSize,
+ pos: getPos(),
+ uploader: uploadFile,
+ });
// the meta data of the image component
const meta = useMemo(
@@ -82,9 +84,6 @@ export const CustomImageUploader = (props: {
[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(() => {
@@ -100,27 +99,26 @@ export const CustomImageUploader = (props: {
}
}, [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) => {
- const file = e.target.files?.[0];
- if (file) {
- if (isFileValid(file)) {
- uploadFile(file);
- }
+ async (e: ChangeEvent) => {
+ e.preventDefault();
+ const filesList = e.target.files;
+ if (!filesList) {
+ return;
}
+ await uploadFirstImageAndInsertRemaining({
+ editor,
+ filesList,
+ maxFileSize,
+ pos: getPos(),
+ uploader: uploadFile,
+ });
},
- [uploadFile]
+ [uploadFile, editor, getPos]
);
const getDisplayMessage = useCallback(() => {
- const isUploading = isImageBeingUploaded || existingFile;
+ const isUploading = isImageBeingUploaded;
if (failedToLoadImage) {
return "Error loading image";
}
@@ -134,13 +132,14 @@ export const CustomImageUploader = (props: {
}
return "Add an image";
- }, [draggedInside, failedToLoadImage, existingFile, isImageBeingUploaded]);
+ }, [draggedInside, failedToLoadImage, isImageBeingUploaded]);
return (
{
- if (!failedToLoadImage) {
+ if (!failedToLoadImage && editor.isEditable) {
fileInputRef.current?.click();
}
}}
@@ -167,6 +166,7 @@ export const CustomImageUploader = (props: {
type="file"
accept=".jpg,.jpeg,.png,.webp"
onChange={onFileChange}
+ multiple
/>
);
diff --git a/packages/editor/src/core/extensions/custom-image/custom-image.ts b/packages/editor/src/core/extensions/custom-image/custom-image.ts
index 939d97668f..2c5e2bb8d4 100644
--- a/packages/editor/src/core/extensions/custom-image/custom-image.ts
+++ b/packages/editor/src/core/extensions/custom-image/custom-image.ts
@@ -22,6 +22,8 @@ declare module "@tiptap/core" {
imageComponent: {
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
uploadImage: (file: File) => () => Promise | undefined;
+ restoreImage: (src: string) => () => Promise;
+ getImageSource?: (path: string) => () => string;
};
}
}
@@ -36,7 +38,13 @@ export interface UploadImageExtensionStorage {
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };
export const CustomImageExtension = (props: TFileHandler) => {
- const { upload, delete: deleteImage, restore: restoreImage } = props;
+ const {
+ getAssetSrc,
+ upload,
+ delete: deleteImageFn,
+ restore: restoreImageFn,
+ validation: { maxFileSize },
+ } = props;
return Image.extend, UploadImageExtensionStorage>({
name: "imageComponent",
@@ -78,23 +86,6 @@ export const CustomImageExtension = (props: TFileHandler) => {
return ["image-component", mergeAttributes(HTMLAttributes)];
},
- onCreate(this) {
- const imageSources = new Set();
- this.editor.state.doc.descendants((node) => {
- if (node.type.name === this.name) {
- imageSources.add(node.attrs.src);
- }
- });
- imageSources.forEach(async (src) => {
- try {
- const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
- await restoreImage(assetUrlWithWorkspaceId);
- } catch (error) {
- console.error("Error restoring image: ", error);
- }
- });
- },
-
addKeyboardShortcuts() {
return {
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
@@ -104,16 +95,35 @@ export const CustomImageExtension = (props: TFileHandler) => {
addProseMirrorPlugins() {
return [
- TrackImageDeletionPlugin(this.editor, deleteImage, this.name),
- TrackImageRestorationPlugin(this.editor, restoreImage, this.name),
+ TrackImageDeletionPlugin(this.editor, deleteImageFn, this.name),
+ TrackImageRestorationPlugin(this.editor, restoreImageFn, this.name),
];
},
+ onCreate(this) {
+ const imageSources = new Set();
+ this.editor.state.doc.descendants((node) => {
+ if (node.type.name === this.name) {
+ if (!node.attrs.src?.startsWith("http")) return;
+
+ imageSources.add(node.attrs.src);
+ }
+ });
+ imageSources.forEach(async (src) => {
+ try {
+ await restoreImageFn(src);
+ } catch (error) {
+ console.error("Error restoring image: ", error);
+ }
+ });
+ },
+
addStorage() {
return {
fileMap: new Map(),
deletedImageSet: new Map(),
uploadInProgress: false,
+ maxFileSize,
};
},
@@ -123,7 +133,13 @@ export const CustomImageExtension = (props: TFileHandler) => {
(props: { file?: File; pos?: number; event: "insert" | "drop" }) =>
({ commands }) => {
// Early return if there's an invalid file being dropped
- if (props?.file && !isFileValid(props.file)) {
+ if (
+ props?.file &&
+ !isFileValid({
+ file: props.file,
+ maxFileSize,
+ })
+ ) {
return false;
}
@@ -166,6 +182,10 @@ export const CustomImageExtension = (props: TFileHandler) => {
const fileUrl = await upload(file);
return fileUrl;
},
+ restoreImage: (src: string) => async () => {
+ await restoreImageFn(src);
+ },
+ getImageSource: (path: string) => () => getAssetSrc(path),
};
},
diff --git a/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts b/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts
index f7db8d6b0c..76edacbd0c 100644
--- a/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts
+++ b/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts
@@ -3,9 +3,13 @@ import { Image } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
// components
import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions/custom-image";
+// types
+import { TFileHandler } from "@/types";
-export const CustomReadOnlyImageExtension = () =>
- Image.extend, UploadImageExtensionStorage>({
+export const CustomReadOnlyImageExtension = (props: Pick) => {
+ const { getAssetSrc } = props;
+
+ return Image.extend, UploadImageExtensionStorage>({
name: "imageComponent",
selectable: false,
group: "block",
@@ -51,7 +55,14 @@ export const CustomReadOnlyImageExtension = () =>
};
},
+ addCommands() {
+ return {
+ getImageSource: (path: string) => () => getAssetSrc(path),
+ };
+ },
+
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});
+};
diff --git a/packages/editor/src/core/extensions/drop.tsx b/packages/editor/src/core/extensions/drop.tsx
index 8d66a5f9f5..2044f03bf5 100644
--- a/packages/editor/src/core/extensions/drop.tsx
+++ b/packages/editor/src/core/extensions/drop.tsx
@@ -21,7 +21,7 @@ export const DropHandlerExtension = () =>
if (imageFiles.length > 0) {
const pos = view.state.selection.from;
- insertImages({ editor, files: imageFiles, initialPos: pos, event: "drop" });
+ insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
}
return true;
}
@@ -41,7 +41,7 @@ export const DropHandlerExtension = () =>
if (coordinates) {
const pos = coordinates.pos;
- insertImages({ editor, files: imageFiles, initialPos: pos, event: "drop" });
+ insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
}
return true;
}
@@ -54,7 +54,7 @@ export const DropHandlerExtension = () =>
},
});
-const insertImages = async ({
+export const insertImagesSafely = async ({
editor,
files,
initialPos,
@@ -72,13 +72,6 @@ const insertImages = async ({
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 });
diff --git a/packages/editor/src/core/extensions/extensions.tsx b/packages/editor/src/core/extensions/extensions.tsx
index 34787bd6a6..47361819fe 100644
--- a/packages/editor/src/core/extensions/extensions.tsx
+++ b/packages/editor/src/core/extensions/extensions.tsx
@@ -12,6 +12,7 @@ import {
CustomCodeBlockExtension,
CustomCodeInlineExtension,
CustomCodeMarkPlugin,
+ CustomColorExtension,
CustomHorizontalRule,
CustomImageExtension,
CustomKeymap,
@@ -30,16 +31,11 @@ import {
// helpers
import { isValidHttpUrl } from "@/helpers/common";
// types
-import { DeleteImage, IMentionHighlight, IMentionSuggestion, RestoreImage, UploadImage } from "@/types";
+import { IMentionHighlight, IMentionSuggestion, TFileHandler } from "@/types";
type TArguments = {
enableHistory: boolean;
- fileConfig: {
- deleteFile: DeleteImage;
- restoreFile: RestoreImage;
- cancelUploadImage?: () => void;
- uploadFile: UploadImage;
- };
+ fileHandler: TFileHandler;
mentionConfig: {
mentionSuggestions?: () => Promise;
mentionHighlights?: () => Promise;
@@ -48,123 +44,120 @@ type TArguments = {
tabIndex?: number;
};
-export const CoreEditorExtensions = ({
- enableHistory,
- fileConfig: { deleteFile, restoreFile, cancelUploadImage, uploadFile },
- mentionConfig,
- placeholder,
- tabIndex,
-}: TArguments) => [
- StarterKit.configure({
- bulletList: {
- HTMLAttributes: {
- class: "list-disc pl-7 space-y-2",
+export const CoreEditorExtensions = (args: TArguments) => {
+ const { enableHistory, fileHandler, mentionConfig, placeholder, tabIndex } = args;
+
+ return [
+ StarterKit.configure({
+ bulletList: {
+ HTMLAttributes: {
+ class: "list-disc pl-7 space-y-2",
+ },
},
- },
- orderedList: {
- HTMLAttributes: {
- class: "list-decimal pl-7 space-y-2",
+ orderedList: {
+ HTMLAttributes: {
+ class: "list-decimal pl-7 space-y-2",
+ },
},
- },
- listItem: {
- HTMLAttributes: {
- class: "not-prose space-y-2",
+ listItem: {
+ HTMLAttributes: {
+ class: "not-prose space-y-2",
+ },
},
- },
- code: false,
- codeBlock: false,
- horizontalRule: false,
- blockquote: false,
- dropcursor: {
- class: "text-custom-text-300",
- },
- ...(enableHistory ? {} : { history: false }),
- }),
- CustomQuoteExtension,
- DropHandlerExtension(),
- CustomHorizontalRule.configure({
- HTMLAttributes: {
- class: "my-4 border-custom-border-400",
- },
- }),
- CustomKeymap,
- ListKeymap({ tabIndex }),
- CustomLinkExtension.configure({
- openOnClick: true,
- autolink: true,
- linkOnPaste: true,
- protocols: ["http", "https"],
- validate: (url: string) => isValidHttpUrl(url),
- HTMLAttributes: {
- class:
- "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
- },
- }),
- CustomTypographyExtension,
- ImageExtension(deleteFile, restoreFile, cancelUploadImage).configure({
- HTMLAttributes: {
- class: "rounded-md",
- },
- }),
- CustomImageExtension({
- delete: deleteFile,
- restore: restoreFile,
- upload: uploadFile,
- cancel: cancelUploadImage ?? (() => {}),
- }),
- TiptapUnderline,
- TextStyle,
- TaskList.configure({
- HTMLAttributes: {
- class: "not-prose pl-2 space-y-2",
- },
- }),
- TaskItem.configure({
- HTMLAttributes: {
- class: "relative",
- },
- nested: true,
- }),
- CustomCodeBlockExtension.configure({
- HTMLAttributes: {
- class: "",
- },
- }),
- CustomCodeMarkPlugin,
- CustomCodeInlineExtension,
- Markdown.configure({
- html: true,
- transformPastedText: true,
- breaks: true,
- }),
- Table,
- TableHeader,
- TableCell,
- TableRow,
- CustomMention({
- mentionSuggestions: mentionConfig.mentionSuggestions,
- mentionHighlights: mentionConfig.mentionHighlights,
- readonly: false,
- }),
- Placeholder.configure({
- placeholder: ({ editor, node }) => {
- if (node.type.name === "heading") return `Heading ${node.attrs.level}`;
+ code: false,
+ codeBlock: false,
+ horizontalRule: false,
+ blockquote: false,
+ dropcursor: {
+ class: "text-custom-text-300",
+ },
+ ...(enableHistory ? {} : { history: false }),
+ }),
+ CustomQuoteExtension,
+ DropHandlerExtension(),
+ CustomHorizontalRule.configure({
+ HTMLAttributes: {
+ class: "my-4 border-custom-border-400",
+ },
+ }),
+ CustomKeymap,
+ ListKeymap({ tabIndex }),
+ CustomLinkExtension.configure({
+ openOnClick: true,
+ autolink: true,
+ linkOnPaste: true,
+ protocols: ["http", "https"],
+ validate: (url: string) => isValidHttpUrl(url),
+ HTMLAttributes: {
+ class:
+ "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
+ },
+ }),
+ CustomTypographyExtension,
+ ImageExtension(fileHandler).configure({
+ HTMLAttributes: {
+ class: "rounded-md",
+ },
+ }),
+ CustomImageExtension(fileHandler),
+ TiptapUnderline,
+ TextStyle,
+ TaskList.configure({
+ HTMLAttributes: {
+ class: "not-prose pl-2 space-y-2",
+ },
+ }),
+ TaskItem.configure({
+ HTMLAttributes: {
+ class: "relative",
+ },
+ nested: true,
+ }),
+ CustomCodeBlockExtension.configure({
+ HTMLAttributes: {
+ class: "",
+ },
+ }),
+ CustomCodeMarkPlugin,
+ CustomCodeInlineExtension,
+ Markdown.configure({
+ html: true,
+ transformPastedText: true,
+ breaks: true,
+ }),
+ Table,
+ TableHeader,
+ TableCell,
+ TableRow,
+ CustomMention({
+ mentionSuggestions: mentionConfig.mentionSuggestions,
+ mentionHighlights: mentionConfig.mentionHighlights,
+ readonly: false,
+ }),
+ Placeholder.configure({
+ placeholder: ({ editor, node }) => {
+ if (node.type.name === "heading") return `Heading ${node.attrs.level}`;
- if (editor.storage.imageComponent.uploadInProgress) return "";
+ if (editor.storage.imageComponent.uploadInProgress) return "";
- const shouldHidePlaceholder =
- editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image");
+ const shouldHidePlaceholder =
+ editor.isActive("table") ||
+ editor.isActive("codeBlock") ||
+ editor.isActive("image") ||
+ editor.isActive("imageComponent");
- if (shouldHidePlaceholder) return "";
+ if (shouldHidePlaceholder) return "";
- if (placeholder) {
- if (typeof placeholder === "string") return placeholder;
- else return placeholder(editor.isFocused, editor.getHTML());
- }
+ if (placeholder) {
+ if (typeof placeholder === "string") return placeholder;
+ else return placeholder(editor.isFocused, editor.getHTML());
+ }
- return "Press '/' for commands...";
- },
- includeChildren: true,
- }),
- CharacterCount,
-];
+ return "Press '/' for commands...";
+ },
+ includeChildren: true,
+ }),
+ CharacterCount,
+ CustomColorExtension,
+ ];
+};
diff --git a/packages/editor/src/core/extensions/image/extension.tsx b/packages/editor/src/core/extensions/image/extension.tsx
index 1f15846a1a..f7666bfe24 100644
--- a/packages/editor/src/core/extensions/image/extension.tsx
+++ b/packages/editor/src/core/extensions/image/extension.tsx
@@ -5,22 +5,30 @@ import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-par
// plugins
import { ImageExtensionStorage, TrackImageDeletionPlugin, TrackImageRestorationPlugin } from "@/plugins/image";
// types
-import { DeleteImage, RestoreImage } from "@/types";
+import { TFileHandler } from "@/types";
// extensions
import { CustomImageNode } from "@/extensions";
-export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreImage, cancelUploadImage?: () => void) =>
- ImageExt.extend({
+export const ImageExtension = (fileHandler: TFileHandler) => {
+ const {
+ getAssetSrc,
+ delete: deleteImageFn,
+ restore: restoreImageFn,
+ validation: { maxFileSize },
+ } = fileHandler;
+
+ return ImageExt.extend({
addKeyboardShortcuts() {
return {
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
};
},
+
addProseMirrorPlugins() {
return [
- TrackImageDeletionPlugin(this.editor, deleteImage, this.name),
- TrackImageRestorationPlugin(this.editor, restoreImage, this.name),
+ TrackImageDeletionPlugin(this.editor, deleteImageFn, this.name),
+ TrackImageRestorationPlugin(this.editor, restoreImageFn, this.name),
];
},
@@ -28,13 +36,14 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm
const imageSources = new Set();
this.editor.state.doc.descendants((node) => {
if (node.type.name === this.name) {
+ if (!node.attrs.src?.startsWith("http")) return;
+
imageSources.add(node.attrs.src);
}
});
imageSources.forEach(async (src) => {
try {
- const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
- await restoreImage(assetUrlWithWorkspaceId);
+ await restoreImageFn(src);
} catch (error) {
console.error("Error restoring image: ", error);
}
@@ -46,6 +55,7 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm
return {
deletedImageSet: new Map(),
uploadInProgress: false,
+ maxFileSize,
};
},
@@ -58,6 +68,15 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm
height: {
default: null,
},
+ aspectRatio: {
+ default: null,
+ },
+ };
+ },
+
+ addCommands() {
+ return {
+ getImageSource: (path: string) => () => getAssetSrc(path),
};
},
@@ -66,3 +85,4 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm
return ReactNodeViewRenderer(CustomImageNode);
},
});
+};
diff --git a/packages/editor/src/core/extensions/image/image-extension-without-props.tsx b/packages/editor/src/core/extensions/image/image-extension-without-props.tsx
index bd9ca3c820..52e277a77d 100644
--- a/packages/editor/src/core/extensions/image/image-extension-without-props.tsx
+++ b/packages/editor/src/core/extensions/image/image-extension-without-props.tsx
@@ -14,6 +14,9 @@ export const ImageExtensionWithoutProps = () =>
height: {
default: null,
},
+ aspectRatio: {
+ default: null,
+ },
};
},
diff --git a/packages/editor/src/core/extensions/image/read-only-image.tsx b/packages/editor/src/core/extensions/image/read-only-image.tsx
index 1605174b32..c884a43ee7 100644
--- a/packages/editor/src/core/extensions/image/read-only-image.tsx
+++ b/packages/editor/src/core/extensions/image/read-only-image.tsx
@@ -2,20 +2,36 @@ import Image from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomImageNode } from "@/extensions";
+// types
+import { TFileHandler } from "@/types";
-export const ReadOnlyImageExtension = Image.extend({
- addAttributes() {
- return {
- ...this.parent?.(),
- width: {
- default: "35%",
- },
- height: {
- default: null,
- },
- };
- },
- addNodeView() {
- return ReactNodeViewRenderer(CustomImageNode);
- },
-});
+export const ReadOnlyImageExtension = (props: Pick) => {
+ const { getAssetSrc } = props;
+
+ return Image.extend({
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+ width: {
+ default: "35%",
+ },
+ height: {
+ default: null,
+ },
+ aspectRatio: {
+ default: null,
+ },
+ };
+ },
+
+ addCommands() {
+ return {
+ getImageSource: (path: string) => () => getAssetSrc(path),
+ };
+ },
+
+ addNodeView() {
+ return ReactNodeViewRenderer(CustomImageNode);
+ },
+ });
+};
diff --git a/packages/editor/src/core/extensions/index.ts b/packages/editor/src/core/extensions/index.ts
index 9209f9480f..5fe19760f2 100644
--- a/packages/editor/src/core/extensions/index.ts
+++ b/packages/editor/src/core/extensions/index.ts
@@ -6,10 +6,12 @@ export * from "./custom-list-keymap";
export * from "./image";
export * from "./issue-embed";
export * from "./mentions";
+export * from "./slash-commands";
export * from "./table";
export * from "./typography";
export * from "./core-without-props";
export * from "./custom-code-inline";
+export * from "./custom-color";
export * from "./drop";
export * from "./enter-key-extension";
export * from "./extensions";
diff --git a/packages/editor/src/core/extensions/read-only-extensions.tsx b/packages/editor/src/core/extensions/read-only-extensions.tsx
index 1c0a9add7a..cd3bbb38f4 100644
--- a/packages/editor/src/core/extensions/read-only-extensions.tsx
+++ b/packages/editor/src/core/extensions/read-only-extensions.tsx
@@ -21,93 +21,108 @@ import {
CustomMention,
HeadingListExtension,
CustomReadOnlyImageExtension,
+ CustomColorExtension,
} from "@/extensions";
// helpers
import { isValidHttpUrl } from "@/helpers/common";
// types
-import { IMentionHighlight } from "@/types";
+import { IMentionHighlight, TFileHandler } from "@/types";
-export const CoreReadOnlyEditorExtensions = (mentionConfig: {
- mentionHighlights?: () => Promise;
-}) => [
- StarterKit.configure({
- bulletList: {
- HTMLAttributes: {
- class: "list-disc pl-7 space-y-2",
+type Props = {
+ fileHandler: Pick;
+ mentionConfig: {
+ mentionHighlights?: () => Promise;
+ };
+};
+
+export const CoreReadOnlyEditorExtensions = (props: Props) => {
+ const { fileHandler, mentionConfig } = props;
+
+ return [
+ StarterKit.configure({
+ bulletList: {
+ HTMLAttributes: {
+ class: "list-disc pl-7 space-y-2",
+ },
},
- },
- orderedList: {
- HTMLAttributes: {
- class: "list-decimal pl-7 space-y-2",
+ orderedList: {
+ HTMLAttributes: {
+ class: "list-decimal pl-7 space-y-2",
+ },
},
- },
- listItem: {
- HTMLAttributes: {
- class: "not-prose space-y-2",
+ listItem: {
+ HTMLAttributes: {
+ class: "not-prose space-y-2",
+ },
},
- },
- code: false,
- codeBlock: false,
- horizontalRule: false,
- blockquote: false,
- dropcursor: false,
- gapcursor: false,
- }),
- CustomQuoteExtension,
- CustomHorizontalRule.configure({
- HTMLAttributes: {
- class: "my-4 border-custom-border-400",
- },
- }),
- CustomLinkExtension.configure({
- openOnClick: true,
- autolink: true,
- linkOnPaste: true,
- protocols: ["http", "https"],
- validate: (url: string) => isValidHttpUrl(url),
- HTMLAttributes: {
- class:
- "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
- },
- }),
- CustomTypographyExtension,
- ReadOnlyImageExtension.configure({
- HTMLAttributes: {
- class: "rounded-md",
- },
- }),
- CustomReadOnlyImageExtension(),
- TiptapUnderline,
- TextStyle,
- TaskList.configure({
- HTMLAttributes: {
- class: "not-prose pl-2 space-y-2",
- },
- }),
- TaskItem.configure({
- HTMLAttributes: {
- class: "relative pointer-events-none",
- },
- nested: true,
- }),
- CustomCodeBlockExtension.configure({
- HTMLAttributes: {
- class: "",
- },
- }),
- CustomCodeInlineExtension,
- Markdown.configure({
- html: true,
- transformCopiedText: true,
- }),
- Table,
- TableHeader,
- TableCell,
- TableRow,
- CustomMention({
- mentionHighlights: mentionConfig.mentionHighlights,
- readonly: true,
- }),
- CharacterCount,
- HeadingListExtension,
-];
+ code: false,
+ codeBlock: false,
+ horizontalRule: false,
+ blockquote: false,
+ dropcursor: false,
+ gapcursor: false,
+ }),
+ CustomQuoteExtension,
+ CustomHorizontalRule.configure({
+ HTMLAttributes: {
+ class: "my-4 border-custom-border-400",
+ },
+ }),
+ CustomLinkExtension.configure({
+ openOnClick: true,
+ autolink: true,
+ linkOnPaste: true,
+ protocols: ["http", "https"],
+ validate: (url: string) => isValidHttpUrl(url),
+ HTMLAttributes: {
+ class:
+ "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
+ },
+ }),
+ CustomTypographyExtension,
+ ReadOnlyImageExtension({
+ getAssetSrc: fileHandler.getAssetSrc,
+ }).configure({
+ HTMLAttributes: {
+ class: "rounded-md",
+ },
+ }),
+ CustomReadOnlyImageExtension({
+ getAssetSrc: fileHandler.getAssetSrc,
+ }),
+ TiptapUnderline,
+ TextStyle,
+ TaskList.configure({
+ HTMLAttributes: {
+ class: "not-prose pl-2 space-y-2",
+ },
+ }),
+ TaskItem.configure({
+ HTMLAttributes: {
+ class: "relative pointer-events-none",
+ },
+ nested: true,
+ }),
+ CustomCodeBlockExtension.configure({
+ HTMLAttributes: {
+ class: "",
+ },
+ }),
+ CustomCodeInlineExtension,
+ Markdown.configure({
+ html: true,
+ transformCopiedText: true,
+ }),
+ Table,
+ TableHeader,
+ TableCell,
+ TableRow,
+ CustomMention({
+ mentionHighlights: mentionConfig.mentionHighlights,
+ readonly: true,
+ }),
+ CharacterCount,
+ CustomColorExtension,
+ HeadingListExtension,
+ ];
+};
diff --git a/packages/editor/src/core/extensions/side-menu.tsx b/packages/editor/src/core/extensions/side-menu.tsx
index 616e315e20..5ab6fbdf5b 100644
--- a/packages/editor/src/core/extensions/side-menu.tsx
+++ b/packages/editor/src/core/extensions/side-menu.tsx
@@ -42,7 +42,7 @@ export const SideMenuExtension = (props: Props) => {
ai: aiEnabled,
dragDrop: dragDropEnabled,
},
- scrollThreshold: { up: 300, down: 100 },
+ scrollThreshold: { up: 200, down: 100 },
}),
];
},
diff --git a/packages/editor/src/core/extensions/slash-commands.tsx b/packages/editor/src/core/extensions/slash-commands.tsx
deleted file mode 100644
index 2be8d89d96..0000000000
--- a/packages/editor/src/core/extensions/slash-commands.tsx
+++ /dev/null
@@ -1,422 +0,0 @@
-import { useState, useEffect, useCallback, ReactNode, useRef, useLayoutEffect } from "react";
-import { Editor, Range, Extension } from "@tiptap/core";
-import { ReactRenderer } from "@tiptap/react";
-import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
-import tippy from "tippy.js";
-import {
- CaseSensitive,
- Code2,
- Heading1,
- Heading2,
- Heading3,
- Heading4,
- Heading5,
- Heading6,
- ImageIcon,
- List,
- ListOrdered,
- ListTodo,
- MinusSquare,
- Quote,
- Table,
-} from "lucide-react";
-// helpers
-import { cn } from "@/helpers/common";
-import {
- insertTableCommand,
- toggleBlockquote,
- toggleBulletList,
- toggleOrderedList,
- toggleTaskList,
- toggleHeadingOne,
- toggleHeadingTwo,
- toggleHeadingThree,
- toggleHeadingFour,
- toggleHeadingFive,
- toggleHeadingSix,
- insertImage,
-} from "@/helpers/editor-commands";
-// types
-import { CommandProps, ISlashCommandItem } from "@/types";
-
-interface CommandItemProps {
- key: string;
- title: string;
- description: string;
- icon: ReactNode;
-}
-
-export type SlashCommandOptions = {
- suggestion: Omit;
-};
-
-const Command = Extension.create({
- name: "slash-command",
- addOptions() {
- return {
- suggestion: {
- char: "/",
- command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
- props.command({ editor, range });
- },
- allow({ editor }: { editor: Editor }) {
- const { selection } = editor.state;
-
- const parentNode = selection.$from.node(selection.$from.depth);
- const blockType = parentNode.type.name;
-
- if (blockType === "codeBlock") {
- return false;
- }
-
- if (editor.isActive("table")) {
- return false;
- }
-
- return true;
- },
- },
- };
- },
- addProseMirrorPlugins() {
- return [
- Suggestion({
- editor: this.editor,
- ...this.options.suggestion,
- }),
- ];
- },
-});
-
-const getSuggestionItems =
- (additionalOptions?: Array) =>
- ({ query }: { query: string }) => {
- let slashCommands: ISlashCommandItem[] = [
- {
- key: "text",
- title: "Text",
- description: "Just start typing with plain text.",
- searchTerms: ["p", "paragraph"],
- icon: ,
- command: ({ editor, range }: CommandProps) => {
- if (range) {
- editor.chain().focus().deleteRange(range).clearNodes().run();
- }
- editor.chain().focus().clearNodes().run();
- },
- },
- {
- key: "h1",
- title: "Heading 1",
- description: "Big section heading.",
- searchTerms: ["title", "big", "large"],
- icon: ,
- command: ({ editor, range }: CommandProps) => {
- toggleHeadingOne(editor, range);
- },
- },
- {
- key: "h2",
- title: "Heading 2",
- description: "Medium section heading.",
- searchTerms: ["subtitle", "medium"],
- icon: ,
- command: ({ editor, range }: CommandProps) => {
- toggleHeadingTwo(editor, range);
- },
- },
- {
- key: "h3",
- title: "Heading 3",
- description: "Small section heading.",
- searchTerms: ["subtitle", "small"],
- icon: ,
- command: ({ editor, range }: CommandProps) => {
- toggleHeadingThree(editor, range);
- },
- },
- {
- key: "h4",
- title: "Heading 4",
- description: "Small section heading.",
- searchTerms: ["subtitle", "small"],
- icon: ,
- command: ({ editor, range }: CommandProps) => {
- toggleHeadingFour(editor, range);
- },
- },
- {
- key: "h5",
- title: "Heading 5",
- description: "Small section heading.",
- searchTerms: ["subtitle", "small"],
- icon: ,
- command: ({ editor, range }: CommandProps) => {
- toggleHeadingFive(editor, range);
- },
- },
- {
- key: "h6",
- title: "Heading 6",
- description: "Small section heading.",
- searchTerms: ["subtitle", "small"],
- icon: ,
- command: ({ editor, range }: CommandProps) => {
- toggleHeadingSix(editor, range);
- },
- },
- {
- key: "to-do-list",
- title: "To do",
- description: "Track tasks with a to-do list.",
- searchTerms: ["todo", "task", "list", "check", "checkbox"],
- icon: ,
- command: ({ editor, range }: CommandProps) => {
- toggleTaskList(editor, range);
- },
- },
- {
- key: "bulleted-list",
- title: "Bullet list",
- description: "Create a simple bullet list.",
- searchTerms: ["unordered", "point"],
- icon:
,
- command: ({ editor, range }: CommandProps) => {
- toggleBulletList(editor, range);
- },
- },
- {
- key: "numbered-list",
- title: "Numbered list",
- description: "Create a list with numbering.",
- searchTerms: ["ordered"],
- icon: ,
- command: ({ editor, range }: CommandProps) => {
- toggleOrderedList(editor, range);
- },
- },
- {
- key: "table",
- title: "Table",
- description: "Create a table",
- searchTerms: ["table", "cell", "db", "data", "tabular"],
- icon: ,
- command: ({ editor, range }: CommandProps) => {
- insertTableCommand(editor, range);
- },
- },
- {
- key: "quote",
- title: "Quote",
- description: "Capture a quote.",
- searchTerms: ["blockquote"],
- icon:
,
- command: ({ editor, range }: CommandProps) => toggleBlockquote(editor, range),
- },
- {
- key: "code",
- title: "Code",
- description: "Capture a code snippet.",
- searchTerms: ["codeblock"],
- icon: ,
- command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
- },
- {
- key: "image",
- title: "Image",
- icon: ,
- description: "Insert an image",
- searchTerms: ["img", "photo", "picture", "media", "upload"],
- command: ({ editor, range }: CommandProps) => insertImage({ editor, event: "insert", range }),
- },
- {
- key: "divider",
- title: "Divider",
- description: "Visually divide blocks.",
- searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
- icon: ,
- command: ({ editor, range }: CommandProps) => {
- editor.chain().focus().deleteRange(range).setHorizontalRule().run();
- },
- },
- ];
-
- if (additionalOptions) {
- additionalOptions.map((item) => {
- slashCommands.push(item);
- });
- }
-
- slashCommands = slashCommands.filter((item) => {
- if (typeof query === "string" && query.length > 0) {
- const search = query.toLowerCase();
- return (
- item.title.toLowerCase().includes(search) ||
- item.description.toLowerCase().includes(search) ||
- (item.searchTerms && item.searchTerms.some((term: string) => term.includes(search)))
- );
- }
- return true;
- });
-
- return slashCommands;
- };
-
-export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
- const containerHeight = container.offsetHeight;
- const itemHeight = item ? item.offsetHeight : 0;
-
- const top = item.offsetTop;
- const bottom = top + itemHeight;
-
- if (top < container.scrollTop) {
- container.scrollTop -= container.scrollTop - top + 5;
- } else if (bottom > containerHeight + container.scrollTop) {
- container.scrollTop += bottom - containerHeight - container.scrollTop + 5;
- }
-};
-
-const CommandList = ({ items, command }: { items: CommandItemProps[]; command: any; editor: any; range: any }) => {
- // states
- const [selectedIndex, setSelectedIndex] = useState(0);
- // refs
- const commandListContainer = useRef(null);
-
- const selectItem = useCallback(
- (index: number) => {
- const item = items[index];
- if (item) command(item);
- },
- [command, items]
- );
-
- useEffect(() => {
- const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
- const onKeyDown = (e: KeyboardEvent) => {
- if (navigationKeys.includes(e.key)) {
- e.preventDefault();
- if (e.key === "ArrowUp") {
- setSelectedIndex((selectedIndex + items.length - 1) % items.length);
- return true;
- }
- if (e.key === "ArrowDown") {
- setSelectedIndex((selectedIndex + 1) % items.length);
- return true;
- }
- if (e.key === "Enter") {
- selectItem(selectedIndex);
- return true;
- }
- return false;
- }
- };
- document.addEventListener("keydown", onKeyDown);
- return () => {
- document.removeEventListener("keydown", onKeyDown);
- };
- }, [items, selectedIndex, setSelectedIndex, selectItem]);
-
- useEffect(() => {
- setSelectedIndex(0);
- }, [items]);
-
- useLayoutEffect(() => {
- const container = commandListContainer?.current;
-
- const item = container?.children[selectedIndex] as HTMLElement;
-
- if (item && container) updateScrollView(container, item);
- }, [selectedIndex]);
-
- if (items.length <= 0) return null;
-
- return (
-
- {items.map((item, index) => (
-
- ))}
-
- );
-};
-
-interface CommandListInstance {
- onKeyDown: (props: { event: KeyboardEvent }) => boolean;
-}
-
-const renderItems = () => {
- let component: ReactRenderer | null = null;
- let popup: any | null = null;
- return {
- onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
- component = new ReactRenderer(CommandList, {
- props,
- editor: props.editor,
- });
-
- const tippyContainer =
- document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]');
-
- // @ts-expect-error Tippy overloads are messed up
- popup = tippy("body", {
- getReferenceClientRect: props.clientRect,
- appendTo: tippyContainer,
- content: component.element,
- showOnCreate: true,
- interactive: true,
- trigger: "manual",
- placement: "bottom-start",
- });
- },
- onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
- component?.updateProps(props);
-
- popup &&
- popup[0].setProps({
- getReferenceClientRect: props.clientRect,
- });
- },
- onKeyDown: (props: { event: KeyboardEvent }) => {
- if (props.event.key === "Escape") {
- popup?.[0].hide();
-
- return true;
- }
-
- if (component?.ref?.onKeyDown(props)) {
- return true;
- }
- return false;
- },
- onExit: () => {
- popup?.[0].destroy();
- component?.destroy();
- },
- };
-};
-
-export const SlashCommand = (additionalOptions?: Array) =>
- Command.configure({
- suggestion: {
- items: getSuggestionItems(additionalOptions),
- render: renderItems,
- },
- });
diff --git a/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx b/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx
new file mode 100644
index 0000000000..94cfb4c77a
--- /dev/null
+++ b/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx
@@ -0,0 +1,294 @@
+import {
+ ALargeSmall,
+ CaseSensitive,
+ Code2,
+ Heading1,
+ Heading2,
+ Heading3,
+ Heading4,
+ Heading5,
+ Heading6,
+ ImageIcon,
+ List,
+ ListOrdered,
+ ListTodo,
+ MinusSquare,
+ Quote,
+ Table,
+} from "lucide-react";
+// constants
+import { COLORS_LIST } from "@/constants/common";
+// helpers
+import {
+ insertTableCommand,
+ toggleBlockquote,
+ toggleBulletList,
+ toggleOrderedList,
+ toggleTaskList,
+ toggleHeadingOne,
+ toggleHeadingTwo,
+ toggleHeadingThree,
+ toggleHeadingFour,
+ toggleHeadingFive,
+ toggleHeadingSix,
+ toggleTextColor,
+ toggleBackgroundColor,
+ insertImage,
+} from "@/helpers/editor-commands";
+// types
+import { CommandProps, ISlashCommandItem } from "@/types";
+
+export type TSlashCommandSection = {
+ key: string;
+ title?: string;
+ items: ISlashCommandItem[];
+};
+
+export const getSlashCommandFilteredSections =
+ (additionalOptions?: ISlashCommandItem[]) =>
+ ({ query }: { query: string }): TSlashCommandSection[] => {
+ const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [
+ {
+ key: "general",
+ items: [
+ {
+ commandKey: "text",
+ key: "text",
+ title: "Text",
+ description: "Just start typing with plain text.",
+ searchTerms: ["p", "paragraph"],
+ icon: ,
+ command: ({ editor, range }: CommandProps) => {
+ if (range) {
+ editor.chain().focus().deleteRange(range).clearNodes().run();
+ }
+ editor.chain().focus().clearNodes().run();
+ },
+ },
+ {
+ commandKey: "h1",
+ key: "h1",
+ title: "Heading 1",
+ description: "Big section heading.",
+ searchTerms: ["title", "big", "large"],
+ icon: ,
+ command: ({ editor, range }) => toggleHeadingOne(editor, range),
+ },
+ {
+ commandKey: "h2",
+ key: "h2",
+ title: "Heading 2",
+ description: "Medium section heading.",
+ searchTerms: ["subtitle", "medium"],
+ icon: ,
+ command: ({ editor, range }) => toggleHeadingTwo(editor, range),
+ },
+ {
+ commandKey: "h3",
+ key: "h3",
+ title: "Heading 3",
+ description: "Small section heading.",
+ searchTerms: ["subtitle", "small"],
+ icon: ,
+ command: ({ editor, range }) => toggleHeadingThree(editor, range),
+ },
+ {
+ commandKey: "h4",
+ key: "h4",
+ title: "Heading 4",
+ description: "Small section heading.",
+ searchTerms: ["subtitle", "small"],
+ icon: ,
+ command: ({ editor, range }) => toggleHeadingFour(editor, range),
+ },
+ {
+ commandKey: "h5",
+ key: "h5",
+ title: "Heading 5",
+ description: "Small section heading.",
+ searchTerms: ["subtitle", "small"],
+ icon: ,
+ command: ({ editor, range }) => toggleHeadingFive(editor, range),
+ },
+ {
+ commandKey: "h6",
+ key: "h6",
+ title: "Heading 6",
+ description: "Small section heading.",
+ searchTerms: ["subtitle", "small"],
+ icon: ,
+ command: ({ editor, range }) => toggleHeadingSix(editor, range),
+ },
+ {
+ commandKey: "to-do-list",
+ key: "to-do-list",
+ title: "To do",
+ description: "Track tasks with a to-do list.",
+ searchTerms: ["todo", "task", "list", "check", "checkbox"],
+ icon: ,
+ command: ({ editor, range }) => toggleTaskList(editor, range),
+ },
+ {
+ commandKey: "bulleted-list",
+ key: "bulleted-list",
+ title: "Bullet list",
+ description: "Create a simple bullet list.",
+ searchTerms: ["unordered", "point"],
+ icon:
,
+ command: ({ editor, range }) => toggleBulletList(editor, range),
+ },
+ {
+ commandKey: "numbered-list",
+ key: "numbered-list",
+ title: "Numbered list",
+ description: "Create a list with numbering.",
+ searchTerms: ["ordered"],
+ icon: ,
+ command: ({ editor, range }) => toggleOrderedList(editor, range),
+ },
+ {
+ commandKey: "table",
+ key: "table",
+ title: "Table",
+ description: "Create a table",
+ searchTerms: ["table", "cell", "db", "data", "tabular"],
+ icon: ,
+ command: ({ editor, range }) => insertTableCommand(editor, range),
+ },
+ {
+ commandKey: "quote",
+ key: "quote",
+ title: "Quote",
+ description: "Capture a quote.",
+ searchTerms: ["blockquote"],
+ icon:
,
+ command: ({ editor, range }) => toggleBlockquote(editor, range),
+ },
+ {
+ commandKey: "code",
+ key: "code",
+ title: "Code",
+ description: "Capture a code snippet.",
+ searchTerms: ["codeblock"],
+ icon: ,
+ command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
+ },
+ {
+ commandKey: "image",
+ key: "image",
+ title: "Image",
+ icon: ,
+ description: "Insert an image",
+ searchTerms: ["img", "photo", "picture", "media", "upload"],
+ command: ({ editor, range }: CommandProps) => insertImage({ editor, event: "insert", range }),
+ },
+ {
+ commandKey: "divider",
+ key: "divider",
+ title: "Divider",
+ description: "Visually divide blocks.",
+ searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
+ icon: ,
+ command: ({ editor, range }) => editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
+ },
+ ],
+ },
+ {
+ key: "text-color",
+ title: "Colors",
+ items: [
+ {
+ commandKey: "text-color",
+ key: "text-color-default",
+ title: "Default",
+ description: "Change text color",
+ searchTerms: ["color", "text", "default"],
+ icon: (
+
+ ),
+ command: ({ editor, range }) => toggleTextColor(undefined, editor, range),
+ },
+ ...COLORS_LIST.map(
+ (color) =>
+ ({
+ commandKey: "text-color",
+ key: `text-color-${color.key}`,
+ title: color.label,
+ description: "Change text color",
+ searchTerms: ["color", "text", color.label],
+ icon: (
+
+ ),
+ command: ({ editor, range }) => toggleTextColor(color.key, editor, range),
+ }) as ISlashCommandItem
+ ),
+ ],
+ },
+ {
+ key: "background-color",
+ title: "Background colors",
+ items: [
+ {
+ commandKey: "background-color",
+ key: "background-color-default",
+ title: "Default background",
+ description: "Change background color",
+ searchTerms: ["color", "bg", "background", "default"],
+ icon: ,
+ iconContainerStyle: {
+ borderRadius: "4px",
+ backgroundColor: "rgba(var(--color-background-100))",
+ border: "1px solid rgba(var(--color-border-300))",
+ },
+ command: ({ editor, range }) => toggleTextColor(undefined, editor, range),
+ },
+ ...COLORS_LIST.map(
+ (color) =>
+ ({
+ commandKey: "background-color",
+ key: `background-color-${color.key}`,
+ title: color.label,
+ description: "Change background color",
+ searchTerms: ["color", "bg", "background", color.label],
+ icon: ,
+ iconContainerStyle: {
+ borderRadius: "4px",
+ backgroundColor: color.backgroundColor,
+ },
+ command: ({ editor, range }) => toggleBackgroundColor(color.key, editor, range),
+ }) as ISlashCommandItem
+ ),
+ ],
+ },
+ ];
+
+ additionalOptions?.map((item) => {
+ SLASH_COMMAND_SECTIONS?.[0]?.items.push(item);
+ });
+
+ const filteredSlashSections = SLASH_COMMAND_SECTIONS.map((section) => ({
+ ...section,
+ items: section.items.filter((item) => {
+ if (typeof query !== "string") return;
+
+ const lowercaseQuery = query.toLowerCase();
+ return (
+ item.title.toLowerCase().includes(lowercaseQuery) ||
+ item.description.toLowerCase().includes(lowercaseQuery) ||
+ item.searchTerms.some((t) => t.includes(lowercaseQuery))
+ );
+ }),
+ }));
+
+ return filteredSlashSections.filter((s) => s.items.length !== 0);
+ };
diff --git a/packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx b/packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx
new file mode 100644
index 0000000000..3a03c3b6a7
--- /dev/null
+++ b/packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx
@@ -0,0 +1,37 @@
+// helpers
+import { cn } from "@/helpers/common";
+// types
+import { ISlashCommandItem } from "@/types";
+
+type Props = {
+ isSelected: boolean;
+ item: ISlashCommandItem;
+ itemIndex: number;
+ onClick: (e: React.MouseEvent) => void;
+ onMouseEnter: () => void;
+ sectionIndex: number;
+};
+
+export const CommandMenuItem: React.FC = (props) => {
+ const { isSelected, item, itemIndex, onClick, onMouseEnter, sectionIndex } = props;
+
+ return (
+
+ );
+};
diff --git a/packages/editor/src/core/extensions/slash-commands/command-menu.tsx b/packages/editor/src/core/extensions/slash-commands/command-menu.tsx
new file mode 100644
index 0000000000..c6363bc51c
--- /dev/null
+++ b/packages/editor/src/core/extensions/slash-commands/command-menu.tsx
@@ -0,0 +1,125 @@
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
+// components
+import { TSlashCommandSection } from "./command-items-list";
+import { CommandMenuItem } from "./command-menu-item";
+
+type Props = {
+ items: TSlashCommandSection[];
+ command: any;
+};
+
+export const SlashCommandsMenu = (props: Props) => {
+ const { items: sections, command } = props;
+ // states
+ const [selectedIndex, setSelectedIndex] = useState({
+ section: 0,
+ item: 0,
+ });
+ // refs
+ const commandListContainer = useRef(null);
+
+ const selectItem = useCallback(
+ (sectionIndex: number, itemIndex: number) => {
+ const item = sections[sectionIndex]?.items?.[itemIndex];
+ if (item) command(item);
+ },
+ [command, sections]
+ );
+ // handle arrow key navigation
+ useEffect(() => {
+ const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (navigationKeys.includes(e.key)) {
+ e.preventDefault();
+ const currentSection = selectedIndex.section;
+ const currentItem = selectedIndex.item;
+ let nextSection = currentSection;
+ let nextItem = currentItem;
+
+ if (e.key === "ArrowUp") {
+ nextItem = currentItem - 1;
+ if (nextItem < 0) {
+ nextSection = currentSection - 1;
+ if (nextSection < 0) nextSection = sections.length - 1;
+ nextItem = sections[nextSection].items.length - 1;
+ }
+ }
+ if (e.key === "ArrowDown") {
+ nextItem = currentItem + 1;
+ if (nextItem >= sections[currentSection].items.length) {
+ nextSection = currentSection + 1;
+ if (nextSection >= sections.length) nextSection = 0;
+ nextItem = 0;
+ }
+ }
+ if (e.key === "Enter") {
+ selectItem(currentSection, currentItem);
+ }
+ setSelectedIndex({
+ section: nextSection,
+ item: nextItem,
+ });
+ }
+ };
+ document.addEventListener("keydown", onKeyDown);
+ return () => {
+ document.removeEventListener("keydown", onKeyDown);
+ };
+ }, [sections, selectedIndex, setSelectedIndex, selectItem]);
+ // initialize the select index to 0 by default
+ useEffect(() => {
+ setSelectedIndex({
+ section: 0,
+ item: 0,
+ });
+ }, [sections]);
+ // scroll to the dropdown item when navigating via keyboard
+ useLayoutEffect(() => {
+ const container = commandListContainer?.current;
+ if (!container) return;
+
+ const item = container.querySelector(`#item-${selectedIndex.section}-${selectedIndex.item}`) as HTMLElement;
+
+ // use scroll into view to bring the item in view if it is not in view
+ item?.scrollIntoView({ block: "nearest" });
+ }, [sections, selectedIndex]);
+
+ const areSearchResultsEmpty = sections.map((s) => s.items.length).reduce((acc, curr) => acc + curr, 0) === 0;
+
+ if (areSearchResultsEmpty) return null;
+
+ return (
+
+ {sections.map((section, sectionIndex) => (
+
+ {section.title &&
{section.title}
}
+
+ {section.items.map((item, itemIndex) => (
+ {
+ e.stopPropagation();
+ selectItem(sectionIndex, itemIndex);
+ }}
+ onMouseEnter={() =>
+ setSelectedIndex({
+ section: sectionIndex,
+ item: itemIndex,
+ })
+ }
+ sectionIndex={sectionIndex}
+ />
+ ))}
+
+
+ ))}
+
+ );
+};
diff --git a/packages/editor/src/core/extensions/slash-commands/index.ts b/packages/editor/src/core/extensions/slash-commands/index.ts
new file mode 100644
index 0000000000..1efe34c51e
--- /dev/null
+++ b/packages/editor/src/core/extensions/slash-commands/index.ts
@@ -0,0 +1 @@
+export * from "./root";
diff --git a/packages/editor/src/core/extensions/slash-commands/root.tsx b/packages/editor/src/core/extensions/slash-commands/root.tsx
new file mode 100644
index 0000000000..df70820dcf
--- /dev/null
+++ b/packages/editor/src/core/extensions/slash-commands/root.tsx
@@ -0,0 +1,111 @@
+import { Editor, Range, Extension } from "@tiptap/core";
+import { ReactRenderer } from "@tiptap/react";
+import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
+import tippy from "tippy.js";
+// types
+import { ISlashCommandItem } from "@/types";
+// components
+import { getSlashCommandFilteredSections } from "./command-items-list";
+import { SlashCommandsMenu } from "./command-menu";
+
+export type SlashCommandOptions = {
+ suggestion: Omit;
+};
+
+const Command = Extension.create({
+ name: "slash-command",
+ addOptions() {
+ return {
+ suggestion: {
+ char: "/",
+ command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
+ props.command({ editor, range });
+ },
+ allow({ editor }: { editor: Editor }) {
+ const { selection } = editor.state;
+
+ const parentNode = selection.$from.node(selection.$from.depth);
+ const blockType = parentNode.type.name;
+
+ if (blockType === "codeBlock") {
+ return false;
+ }
+
+ if (editor.isActive("table")) {
+ return false;
+ }
+
+ return true;
+ },
+ },
+ };
+ },
+ addProseMirrorPlugins() {
+ return [
+ Suggestion({
+ editor: this.editor,
+ ...this.options.suggestion,
+ }),
+ ];
+ },
+});
+
+interface CommandListInstance {
+ onKeyDown: (props: { event: KeyboardEvent }) => boolean;
+}
+
+const renderItems = () => {
+ let component: ReactRenderer | null = null;
+ let popup: any | null = null;
+ return {
+ onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
+ component = new ReactRenderer(SlashCommandsMenu, {
+ props,
+ editor: props.editor,
+ });
+
+ const tippyContainer =
+ document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]');
+ popup = tippy("body", {
+ getReferenceClientRect: props.clientRect,
+ appendTo: tippyContainer,
+ content: component.element,
+ showOnCreate: true,
+ interactive: true,
+ trigger: "manual",
+ placement: "bottom-start",
+ });
+ },
+ onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
+ component?.updateProps(props);
+
+ popup?.[0]?.setProps({
+ getReferenceClientRect: props.clientRect,
+ });
+ },
+ onKeyDown: (props: { event: KeyboardEvent }) => {
+ if (props.event.key === "Escape") {
+ popup?.[0].hide();
+
+ return true;
+ }
+
+ if (component?.ref?.onKeyDown(props)) {
+ return true;
+ }
+ return false;
+ },
+ onExit: () => {
+ popup?.[0].destroy();
+ component?.destroy();
+ },
+ };
+};
+
+export const SlashCommands = (additionalOptions?: ISlashCommandItem[]) =>
+ Command.configure({
+ suggestion: {
+ items: getSlashCommandFilteredSections(additionalOptions),
+ render: renderItems,
+ },
+ });
diff --git a/packages/editor/src/core/helpers/editor-commands.ts b/packages/editor/src/core/helpers/editor-commands.ts
index 66be05bb26..f4ebb6c2f2 100644
--- a/packages/editor/src/core/helpers/editor-commands.ts
+++ b/packages/editor/src/core/helpers/editor-commands.ts
@@ -154,3 +154,29 @@ export const unsetLinkEditor = (editor: Editor) => {
export const setLinkEditor = (editor: Editor, url: string) => {
editor.chain().focus().setLink({ href: url }).run();
};
+
+export const toggleTextColor = (color: string | undefined, editor: Editor, range?: Range) => {
+ if (color) {
+ if (range) editor.chain().focus().deleteRange(range).setTextColor(color).run();
+ else editor.chain().focus().setTextColor(color).run();
+ } else {
+ if (range) editor.chain().focus().deleteRange(range).unsetTextColor().run();
+ else editor.chain().focus().unsetTextColor().run();
+ }
+};
+
+export const toggleBackgroundColor = (color: string | undefined, editor: Editor, range?: Range) => {
+ if (color) {
+ if (range) {
+ editor.chain().focus().deleteRange(range).setBackgroundColor(color).run();
+ } else {
+ editor.chain().focus().setBackgroundColor(color).run();
+ }
+ } else {
+ if (range) {
+ editor.chain().focus().deleteRange(range).unsetBackgroundColor().run();
+ } else {
+ editor.chain().focus().unsetBackgroundColor().run();
+ }
+ }
+};
diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts
index c79d2204a4..be154c26a6 100644
--- a/packages/editor/src/core/hooks/use-editor.ts
+++ b/packages/editor/src/core/hooks/use-editor.ts
@@ -90,12 +90,7 @@ export const useEditor = (props: CustomEditorProps) => {
extensions: [
...CoreEditorExtensions({
enableHistory,
- fileConfig: {
- uploadFile: fileHandler.upload,
- deleteFile: fileHandler.delete,
- restoreFile: fileHandler.restore,
- cancelUploadImage: fileHandler.cancel,
- },
+ fileHandler,
mentionConfig: {
mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve([])),
mentionHighlights: mentionHandler.highlights,
@@ -141,7 +136,7 @@ export const useEditor = (props: CustomEditorProps) => {
forwardedRef,
() => ({
clearEditor: (emitUpdate = false) => {
- editorRef.current?.commands.clearContent(emitUpdate);
+ editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
},
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
@@ -151,7 +146,8 @@ export const useEditor = (props: CustomEditorProps) => {
insertContentAtSavedSelection(editorRef, content, savedSelection);
}
},
- executeMenuItemCommand: (itemKey: TEditorCommands) => {
+ executeMenuItemCommand: (props) => {
+ const { itemKey } = props;
const editorItems = getEditorMenuItems(editorRef.current);
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
@@ -160,6 +156,8 @@ export const useEditor = (props: CustomEditorProps) => {
if (item) {
if (item.key === "image") {
item.command(savedSelectionRef.current);
+ } else if (itemKey === "text-color" || itemKey === "background-color") {
+ item.command(props.color);
} else {
item.command();
}
@@ -167,12 +165,19 @@ export const useEditor = (props: CustomEditorProps) => {
console.warn(`No command found for item: ${itemKey}`);
}
},
- isMenuItemActive: (itemName: TEditorCommands): boolean => {
+ isMenuItemActive: (props) => {
+ const { itemKey } = props;
const editorItems = getEditorMenuItems(editorRef.current);
- const getEditorMenuItem = (itemName: TEditorCommands) => editorItems.find((item) => item.key === itemName);
- const item = getEditorMenuItem(itemName);
- return item ? item.isActive() : false;
+ const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
+ const item = getEditorMenuItem(itemKey);
+ if (!item) return false;
+
+ if (itemKey === "text-color" || itemKey === "background-color") {
+ return item.isActive(props.color);
+ } else {
+ return item.isActive("");
+ }
},
onHeadingChange: (callback: (headings: IMarking[]) => void) => {
// Subscribe to update event emitted from headers extension
diff --git a/packages/editor/src/core/hooks/use-file-upload.ts b/packages/editor/src/core/hooks/use-file-upload.ts
index 5dfa025e59..f5f930f290 100644
--- a/packages/editor/src/core/hooks/use-file-upload.ts
+++ b/packages/editor/src/core/hooks/use-file-upload.ts
@@ -1,16 +1,20 @@
import { DragEvent, useCallback, useEffect, useState } from "react";
import { Editor } from "@tiptap/core";
+// extensions
+import { insertImagesSafely } from "@/extensions/drop";
+// plugins
import { isFileValid } from "@/plugins/image";
-export const useUploader = ({
- onUpload,
- editor,
- loadImageFromFileSystem,
-}: {
- onUpload: (url: string) => void;
+type TUploaderArgs = {
editor: Editor;
loadImageFromFileSystem: (file: string) => void;
-}) => {
+ maxFileSize: number;
+ onUpload: (url: string) => void;
+};
+
+export const useUploader = (args: TUploaderArgs) => {
+ const { editor, loadImageFromFileSystem, maxFileSize, onUpload } = args;
+ // states
const [uploading, setUploading] = useState(false);
const uploadFile = useCallback(
@@ -22,7 +26,10 @@ export const useUploader = ({
setUploading(true);
const fileNameTrimmed = trimFileName(file.name);
const fileWithTrimmedName = new File([file], fileNameTrimmed, { type: file.type });
- const isValid = isFileValid(fileWithTrimmedName);
+ const isValid = isFileValid({
+ file: fileWithTrimmedName,
+ maxFileSize,
+ });
if (!isValid) {
setImageUploadInProgress(false);
return;
@@ -63,7 +70,16 @@ export const useUploader = ({
return { uploading, uploadFile };
};
-export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) => {
+type TDropzoneArgs = {
+ editor: Editor;
+ maxFileSize: number;
+ pos: number;
+ uploader: (file: File) => Promise;
+};
+
+export const useDropZone = (args: TDropzoneArgs) => {
+ const { editor, maxFileSize, pos, uploader } = args;
+ // states
const [isDragging, setIsDragging] = useState(false);
const [draggedInside, setDraggedInside] = useState(false);
@@ -86,40 +102,22 @@ export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) =>
}, []);
const onDrop = useCallback(
- (e: DragEvent) => {
+ async (e: DragEvent) => {
+ e.preventDefault();
setDraggedInside(false);
if (e.dataTransfer.files.length === 0) {
return;
}
-
- const fileList = e.dataTransfer.files;
-
- const files: File[] = [];
-
- for (let i = 0; i < fileList.length; i += 1) {
- const item = fileList.item(i);
- if (item) {
- files.push(item);
- }
- }
-
- if (files.some((file) => file.type.indexOf("image") === -1)) {
- return;
- }
-
- e.preventDefault();
-
- const filteredFiles = files.filter((f) => f.type.indexOf("image") !== -1);
-
- const file = filteredFiles.length > 0 ? filteredFiles[0] : undefined;
-
- if (file) {
- uploader(file);
- } else {
- console.error("No file found");
- }
+ const filesList = e.dataTransfer.files;
+ await uploadFirstImageAndInsertRemaining({
+ editor,
+ filesList,
+ maxFileSize,
+ pos,
+ uploader,
+ });
},
- [uploader]
+ [uploader, editor, pos]
);
const onDragEnter = () => {
@@ -143,3 +141,51 @@ function trimFileName(fileName: string, maxLength = 100) {
return fileName;
}
+
+type TMultipleImagesArgs = {
+ editor: Editor;
+ filesList: FileList;
+ maxFileSize: number;
+ pos: number;
+ uploader: (file: File) => Promise;
+};
+
+// Upload the first image and insert the remaining images for uploading multiple image
+// post insertion of image-component
+export async function uploadFirstImageAndInsertRemaining(args: TMultipleImagesArgs) {
+ const { editor, filesList, maxFileSize, pos, uploader } = args;
+ const filteredFiles: File[] = [];
+ for (let i = 0; i < filesList.length; i += 1) {
+ const item = filesList.item(i);
+ if (
+ item &&
+ item.type.indexOf("image") !== -1 &&
+ isFileValid({
+ file: item,
+ maxFileSize,
+ })
+ ) {
+ filteredFiles.push(item);
+ }
+ }
+ if (filteredFiles.length !== filesList.length) {
+ console.warn("Some files were not images and have been ignored.");
+ }
+ if (filteredFiles.length === 0) {
+ console.error("No image files found to upload");
+ return;
+ }
+
+ // Upload the first image
+ const firstFile = filteredFiles[0];
+ uploader(firstFile);
+
+ // Insert the remaining images
+ const remainingFiles = filteredFiles.slice(1);
+
+ if (remainingFiles.length > 0) {
+ const docSize = editor.state.doc.content.size;
+ const posOfNextImageToBeInserted = Math.min(pos + 1, docSize);
+ insertImagesSafely({ editor, files: remainingFiles, initialPos: posOfNextImageToBeInserted, event: "drop" });
+ }
+}
diff --git a/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts b/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts
index 1aff29aa74..9fa73c3ecb 100644
--- a/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts
+++ b/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts
@@ -14,6 +14,7 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit
editorClassName,
editorProps = {},
extensions,
+ fileHandler,
forwardedRef,
handleEditorReady,
id,
@@ -74,6 +75,7 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit
document: provider.document,
}),
],
+ fileHandler,
forwardedRef,
handleEditorReady,
mentionHandler,
diff --git a/packages/editor/src/core/hooks/use-read-only-editor.ts b/packages/editor/src/core/hooks/use-read-only-editor.ts
index add0508b99..23ce023adc 100644
--- a/packages/editor/src/core/hooks/use-read-only-editor.ts
+++ b/packages/editor/src/core/hooks/use-read-only-editor.ts
@@ -11,7 +11,7 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
// props
import { CoreReadOnlyEditorProps } from "@/props";
// types
-import { EditorReadOnlyRefApi, IMentionHighlight } from "@/types";
+import { EditorReadOnlyRefApi, IMentionHighlight, TFileHandler } from "@/types";
interface CustomReadOnlyEditorProps {
initialValue?: string;
@@ -19,6 +19,7 @@ interface CustomReadOnlyEditorProps {
forwardedRef?: MutableRefObject;
extensions?: any;
editorProps?: EditorProps;
+ fileHandler: Pick;
handleEditorReady?: (value: boolean) => void;
mentionHandler: {
highlights: () => Promise;
@@ -33,6 +34,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
forwardedRef,
extensions = [],
editorProps = {},
+ fileHandler,
handleEditorReady,
mentionHandler,
provider,
@@ -52,7 +54,10 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
},
extensions: [
...CoreReadOnlyEditorExtensions({
- mentionHighlights: mentionHandler.highlights,
+ mentionConfig: {
+ mentionHighlights: mentionHandler.highlights,
+ },
+ fileHandler,
}),
...extensions,
],
@@ -70,8 +75,8 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
const editorRef: MutableRefObject = useRef(null);
useImperativeHandle(forwardedRef, () => ({
- clearEditor: () => {
- editorRef.current?.commands.clearContent();
+ clearEditor: (emitUpdate = false) => {
+ editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
},
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
diff --git a/packages/editor/src/core/plugins/drag-handle.ts b/packages/editor/src/core/plugins/drag-handle.ts
index eb77d21bc8..7fc30805af 100644
--- a/packages/editor/src/core/plugins/drag-handle.ts
+++ b/packages/editor/src/core/plugins/drag-handle.ts
@@ -253,14 +253,46 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
dragHandleElement.addEventListener("click", (e) => handleClick(e, view));
dragHandleElement.addEventListener("contextmenu", (e) => handleClick(e, view));
+ const isScrollable = (node: HTMLElement | SVGElement) => {
+ if (!(node instanceof HTMLElement || node instanceof SVGElement)) {
+ return false;
+ }
+ const style = getComputedStyle(node);
+ return ["overflow", "overflow-y"].some((propertyName) => {
+ const value = style.getPropertyValue(propertyName);
+ return value === "auto" || value === "scroll";
+ });
+ };
+
+ const getScrollParent = (node: HTMLElement | SVGElement) => {
+ let currentParent = node.parentElement;
+ while (currentParent) {
+ if (isScrollable(currentParent)) {
+ return currentParent;
+ }
+ currentParent = currentParent.parentElement;
+ }
+ return document.scrollingElement || document.documentElement;
+ };
+
+ const maxScrollSpeed = 100;
+
dragHandleElement.addEventListener("drag", (e) => {
hideDragHandle();
- const frameRenderer = document.querySelector(".frame-renderer");
- if (!frameRenderer) return;
- if (e.clientY < options.scrollThreshold.up) {
- frameRenderer.scrollBy({ top: -70, behavior: "smooth" });
- } else if (window.innerHeight - e.clientY < options.scrollThreshold.down) {
- frameRenderer.scrollBy({ top: 70, behavior: "smooth" });
+ const scrollableParent = getScrollParent(dragHandleElement);
+ if (!scrollableParent) return;
+ const scrollThreshold = options.scrollThreshold;
+
+ if (e.clientY < scrollThreshold.up) {
+ const overflow = scrollThreshold.up - e.clientY;
+ const ratio = Math.min(overflow / scrollThreshold.up, 1);
+ const scrollAmount = -maxScrollSpeed * ratio;
+ scrollableParent.scrollBy({ top: scrollAmount });
+ } else if (window.innerHeight - e.clientY < scrollThreshold.down) {
+ const overflow = e.clientY - (window.innerHeight - scrollThreshold.down);
+ const ratio = Math.min(overflow / scrollThreshold.down, 1);
+ const scrollAmount = maxScrollSpeed * ratio;
+ scrollableParent.scrollBy({ top: scrollAmount });
}
});
diff --git a/packages/editor/src/core/plugins/image/delete-image.ts b/packages/editor/src/core/plugins/image/delete-image.ts
index 72bb913ae7..bcede77072 100644
--- a/packages/editor/src/core/plugins/image/delete-image.ts
+++ b/packages/editor/src/core/plugins/image/delete-image.ts
@@ -17,6 +17,8 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag
});
transactions.forEach((transaction) => {
+ // if the transaction has meta of skipImageDeletion get to true, then return (like while clearing the editor content programatically)
+ if (transaction.getMeta("skipImageDeletion")) return;
// transaction could be a selection
if (!transaction.docChanged) return;
@@ -45,10 +47,9 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag
});
async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise {
+ if (!src) return;
try {
- if (!src) return;
- const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
- await deleteImage(assetUrlWithWorkspaceId);
+ await deleteImage(src);
} catch (error) {
console.error("Error deleting image: ", error);
}
diff --git a/packages/editor/src/core/plugins/image/restore-image.ts b/packages/editor/src/core/plugins/image/restore-image.ts
index d722e53a63..4eecf01d7e 100644
--- a/packages/editor/src/core/plugins/image/restore-image.ts
+++ b/packages/editor/src/core/plugins/image/restore-image.ts
@@ -25,6 +25,9 @@ export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: Restor
if (node.type.name !== nodeType) return;
if (pos < 0 || pos > newState.doc.content.size) return;
if (oldImageSources.has(node.attrs.src)) return;
+ // if the src is just a id (private bucket), then we don't need to handle restore from here but
+ // only while it fails to load
+ if (!node.attrs.src?.startsWith("http")) return;
addedImages.push(node as ImageNode);
});
@@ -48,10 +51,9 @@ export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: Restor
});
async function onNodeRestored(src: string, restoreImage: RestoreImage): Promise {
+ if (!src) return;
try {
- if (!src) return;
- const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
- await restoreImage(assetUrlWithWorkspaceId);
+ await restoreImage(src);
} catch (error) {
console.error("Error restoring image: ", error);
throw error;
diff --git a/packages/editor/src/core/plugins/image/utils/validate-file.ts b/packages/editor/src/core/plugins/image/utils/validate-file.ts
index c86e99335f..db88f3f73c 100644
--- a/packages/editor/src/core/plugins/image/utils/validate-file.ts
+++ b/packages/editor/src/core/plugins/image/utils/validate-file.ts
@@ -1,25 +1,26 @@
-export function isFileValid(file: File, showAlert = true): boolean {
+type TArgs = {
+ file: File;
+ maxFileSize: number;
+};
+
+export const isFileValid = (args: TArgs): boolean => {
+ const { file, maxFileSize } = args;
+
if (!file) {
- if (showAlert) {
- alert("No file selected. Please select a file to upload.");
- }
+ alert("No file selected. Please select a file to upload.");
return false;
}
const allowedTypes = ["image/jpeg", "image/jpg", "image/png", "image/webp"];
if (!allowedTypes.includes(file.type)) {
- if (showAlert) {
- alert("Invalid file type. Please select a JPEG, JPG, PNG, or WEBP image file.");
- }
+ alert("Invalid file type. Please select a JPEG, JPG, PNG, or WEBP image file.");
return false;
}
- if (file.size > 5 * 1024 * 1024) {
- if (showAlert) {
- alert("File size too large. Please select a file smaller than 5MB.");
- }
+ if (file.size > maxFileSize) {
+ alert(`File size too large. Please select a file smaller than ${maxFileSize / 1024 / 1024}MB.`);
return false;
}
return true;
-}
+};
diff --git a/packages/editor/src/core/types/collaboration.ts b/packages/editor/src/core/types/collaboration.ts
index 4b706a7f9f..60721a5a66 100644
--- a/packages/editor/src/core/types/collaboration.ts
+++ b/packages/editor/src/core/types/collaboration.ts
@@ -44,5 +44,6 @@ export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & {
};
export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & {
+ fileHandler: Pick;
forwardedRef?: React.MutableRefObject;
};
diff --git a/packages/editor/src/core/types/config.ts b/packages/editor/src/core/types/config.ts
index 93d612e599..67043ef9a1 100644
--- a/packages/editor/src/core/types/config.ts
+++ b/packages/editor/src/core/types/config.ts
@@ -1,10 +1,18 @@
import { DeleteImage, RestoreImage, UploadImage } from "@/types";
export type TFileHandler = {
+ getAssetSrc: (path: string) => string;
cancel: () => void;
delete: DeleteImage;
upload: UploadImage;
restore: RestoreImage;
+ validation: {
+ /**
+ * @description max file size in bytes
+ * @example enter 5242880( 5* 1024 * 1024) for 5MB
+ */
+ maxFileSize: number;
+ };
};
export type TEditorFontStyle = "sans-serif" | "serif" | "monospace";
diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts
index 3624fa046c..31b315c1ca 100644
--- a/packages/editor/src/core/types/editor.ts
+++ b/packages/editor/src/core/types/editor.ts
@@ -6,14 +6,15 @@ import {
IMentionHighlight,
IMentionSuggestion,
TAIHandler,
+ TColorEditorCommands,
TDisplayConfig,
TEditorCommands,
TEmbedConfig,
TExtensions,
TFileHandler,
+ TNonColorEditorCommands,
TServerHandler,
} from "@/types";
-
// editor refs
export type EditorReadOnlyRefApi = {
getMarkDown: () => string;
@@ -36,8 +37,26 @@ export type EditorReadOnlyRefApi = {
export interface EditorRefApi extends EditorReadOnlyRefApi {
setEditorValueAtCursorPosition: (content: string) => void;
- executeMenuItemCommand: (itemKey: TEditorCommands) => void;
- isMenuItemActive: (itemKey: TEditorCommands) => boolean;
+ executeMenuItemCommand: (
+ props:
+ | {
+ itemKey: TNonColorEditorCommands;
+ }
+ | {
+ itemKey: TColorEditorCommands;
+ color: string | undefined;
+ }
+ ) => void;
+ isMenuItemActive: (
+ props:
+ | {
+ itemKey: TNonColorEditorCommands;
+ }
+ | {
+ itemKey: TColorEditorCommands;
+ color: string | undefined;
+ }
+ ) => boolean;
onStateChange: (callback: () => void) => () => void;
setFocusAtPosition: (position: number) => void;
isEditorReadyToDiscard: () => boolean;
@@ -89,6 +108,7 @@ export interface IReadOnlyEditorProps {
containerClassName?: string;
displayConfig?: TDisplayConfig;
editorClassName?: string;
+ fileHandler: Pick;
forwardedRef?: React.MutableRefObject;
id: string;
initialValue: string;
diff --git a/packages/editor/src/core/types/image.ts b/packages/editor/src/core/types/image.ts
index c1b174a480..5c707bf33d 100644
--- a/packages/editor/src/core/types/image.ts
+++ b/packages/editor/src/core/types/image.ts
@@ -1,5 +1,5 @@
-export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise;
+export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise;
-export type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise;
+export type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise;
export type UploadImage = (file: File) => Promise;
diff --git a/packages/editor/src/core/types/slash-commands-suggestion.ts b/packages/editor/src/core/types/slash-commands-suggestion.ts
index 3cb9d76b0e..ce3408a34f 100644
--- a/packages/editor/src/core/types/slash-commands-suggestion.ts
+++ b/packages/editor/src/core/types/slash-commands-suggestion.ts
@@ -1,4 +1,4 @@
-import { ReactNode } from "react";
+import { CSSProperties } from "react";
import { Editor, Range } from "@tiptap/core";
export type TEditorCommands =
@@ -21,7 +21,12 @@ export type TEditorCommands =
| "table"
| "image"
| "divider"
- | "issue-embed";
+ | "issue-embed"
+ | "text-color"
+ | "background-color";
+
+export type TColorEditorCommands = Extract;
+export type TNonColorEditorCommands = Exclude;
export type CommandProps = {
editor: Editor;
@@ -29,10 +34,12 @@ export type CommandProps = {
};
export type ISlashCommandItem = {
- key: TEditorCommands;
+ commandKey: TEditorCommands;
+ key: string;
title: string;
description: string;
searchTerms: string[];
- icon: ReactNode;
+ icon: React.ReactNode;
+ iconContainerStyle?: CSSProperties;
command: ({ editor, range }: CommandProps) => void;
};
diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts
index fc9fe1ac60..292dc53fb2 100644
--- a/packages/editor/src/index.ts
+++ b/packages/editor/src/index.ts
@@ -1,5 +1,6 @@
// styles
// import "./styles/tailwind.css";
+import "src/styles/variables.css";
import "src/styles/editor.css";
import "src/styles/table.css";
import "src/styles/github-dark.css";
@@ -18,6 +19,9 @@ export {
export { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection";
+// constants
+export * from "@/constants/common";
+
// helpers
export * from "@/helpers/common";
export * from "@/helpers/editor-commands";
diff --git a/packages/editor/src/styles/editor.css b/packages/editor/src/styles/editor.css
index e5047fb0c4..20d6b5fa0c 100644
--- a/packages/editor/src/styles/editor.css
+++ b/packages/editor/src/styles/editor.css
@@ -1,61 +1,3 @@
-.editor-container {
- &.large-font {
- --font-size-h1: 1.75rem;
- --font-size-h2: 1.5rem;
- --font-size-h3: 1.375rem;
- --font-size-h4: 1.25rem;
- --font-size-h5: 1.125rem;
- --font-size-h6: 1rem;
- --font-size-regular: 1rem;
- --font-size-list: var(--font-size-regular);
- --font-size-code: var(--font-size-regular);
-
- --line-height-h1: 2.25rem;
- --line-height-h2: 2rem;
- --line-height-h3: 1.75rem;
- --line-height-h4: 1.5rem;
- --line-height-h5: 1.5rem;
- --line-height-h6: 1.5rem;
- --line-height-regular: 1.5rem;
- --line-height-list: var(--line-height-regular);
- --line-height-code: var(--line-height-regular);
- }
-
- &.small-font {
- --font-size-h1: 1.4rem;
- --font-size-h2: 1.2rem;
- --font-size-h3: 1.1rem;
- --font-size-h4: 1rem;
- --font-size-h5: 0.9rem;
- --font-size-h6: 0.8rem;
- --font-size-regular: 0.8rem;
- --font-size-list: var(--font-size-regular);
- --font-size-code: var(--font-size-regular);
-
- --line-height-h1: 1.8rem;
- --line-height-h2: 1.6rem;
- --line-height-h3: 1.4rem;
- --line-height-h4: 1.2rem;
- --line-height-h5: 1.2rem;
- --line-height-h6: 1.2rem;
- --line-height-regular: 1.2rem;
- --line-height-list: var(--line-height-regular);
- --line-height-code: var(--line-height-regular);
- }
-
- &.sans-serif {
- --font-style: sans-serif;
- }
-
- &.serif {
- --font-style: serif;
- }
-
- &.monospace {
- --font-style: monospace;
- }
-}
-
.ProseMirror {
position: relative;
word-wrap: break-word;
@@ -439,3 +381,62 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
margin-top: 0;
}
/* end tailwind typography */
+
+/* text colors */
+[data-text-color="gray"] {
+ color: var(--editor-colors-gray-text);
+}
+[data-text-color="peach"] {
+ color: var(--editor-colors-peach-text);
+}
+[data-text-color="pink"] {
+ color: var(--editor-colors-pink-text);
+}
+[data-text-color="orange"] {
+ color: var(--editor-colors-orange-text);
+}
+[data-text-color="green"] {
+ color: var(--editor-colors-green-text);
+}
+[data-text-color="light-blue"] {
+ color: var(--editor-colors-light-blue-text);
+}
+[data-text-color="dark-blue"] {
+ color: var(--editor-colors-dark-blue-text);
+}
+[data-text-color="purple"] {
+ color: var(--editor-colors-purple-text);
+}
+/* [data-text-color="pink-blue-gradient"] {
+ background-clip: text;
+ color: transparent;
+ background-image: linear-gradient(90deg, #a961cd 50%, #e75962 100%);
+} */
+/* end text colors */
+
+/* background colors */
+[data-background-color="gray"] {
+ background-color: var(--editor-colors-gray-background);
+}
+[data-background-color="peach"] {
+ background-color: var(--editor-colors-peach-background);
+}
+[data-background-color="pink"] {
+ background-color: var(--editor-colors-pink-background);
+}
+[data-background-color="orange"] {
+ background-color: var(--editor-colors-orange-background);
+}
+[data-background-color="green"] {
+ background-color: var(--editor-colors-green-background);
+}
+[data-background-color="light-blue"] {
+ background-color: var(--editor-colors-light-blue-background);
+}
+[data-background-color="dark-blue"] {
+ background-color: var(--editor-colors-dark-blue-background);
+}
+[data-background-color="purple"] {
+ background-color: var(--editor-colors-purple-background);
+}
+/* end background colors */
diff --git a/packages/editor/src/styles/variables.css b/packages/editor/src/styles/variables.css
new file mode 100644
index 0000000000..8b6595b871
--- /dev/null
+++ b/packages/editor/src/styles/variables.css
@@ -0,0 +1,96 @@
+:root {
+ /* text colors */
+ --editor-colors-gray-text: #5c5e63;
+ --editor-colors-peach-text: #ff5b59;
+ --editor-colors-pink-text: #f65385;
+ --editor-colors-orange-text: #fd9038;
+ --editor-colors-green-text: #0fc27b;
+ --editor-colors-light-blue-text: #17bee9;
+ --editor-colors-dark-blue-text: #266df0;
+ --editor-colors-purple-text: #9162f9;
+ /* end text colors */
+}
+
+/* text background colors */
+[data-theme="light"],
+[data-theme="light-contrast"] {
+ --editor-colors-gray-background: #d6d6d8;
+ --editor-colors-peach-background: #ffd5d7;
+ --editor-colors-pink-background: #fdd4e3;
+ --editor-colors-orange-background: #ffe3cd;
+ --editor-colors-green-background: #c3f0de;
+ --editor-colors-light-blue-background: #c5eff9;
+ --editor-colors-dark-blue-background: #c9dafb;
+ --editor-colors-purple-background: #e3d8fd;
+}
+[data-theme="dark"],
+[data-theme="dark-contrast"] {
+ --editor-colors-gray-background: #404144;
+ --editor-colors-peach-background: #593032;
+ --editor-colors-pink-background: #562e3d;
+ --editor-colors-orange-background: #583e2a;
+ --editor-colors-green-background: #1d4a3b;
+ --editor-colors-light-blue-background: #1f495c;
+ --editor-colors-dark-blue-background: #223558;
+ --editor-colors-purple-background: #3d325a;
+}
+/* end text background colors */
+
+.editor-container {
+ /* font sizes and line heights */
+ &.large-font {
+ --font-size-h1: 1.75rem;
+ --font-size-h2: 1.5rem;
+ --font-size-h3: 1.375rem;
+ --font-size-h4: 1.25rem;
+ --font-size-h5: 1.125rem;
+ --font-size-h6: 1rem;
+ --font-size-regular: 1rem;
+ --font-size-list: var(--font-size-regular);
+ --font-size-code: var(--font-size-regular);
+
+ --line-height-h1: 2.25rem;
+ --line-height-h2: 2rem;
+ --line-height-h3: 1.75rem;
+ --line-height-h4: 1.5rem;
+ --line-height-h5: 1.5rem;
+ --line-height-h6: 1.5rem;
+ --line-height-regular: 1.5rem;
+ --line-height-list: var(--line-height-regular);
+ --line-height-code: var(--line-height-regular);
+ }
+ &.small-font {
+ --font-size-h1: 1.4rem;
+ --font-size-h2: 1.2rem;
+ --font-size-h3: 1.1rem;
+ --font-size-h4: 1rem;
+ --font-size-h5: 0.9rem;
+ --font-size-h6: 0.8rem;
+ --font-size-regular: 0.8rem;
+ --font-size-list: var(--font-size-regular);
+ --font-size-code: var(--font-size-regular);
+
+ --line-height-h1: 1.8rem;
+ --line-height-h2: 1.6rem;
+ --line-height-h3: 1.4rem;
+ --line-height-h4: 1.2rem;
+ --line-height-h5: 1.2rem;
+ --line-height-h6: 1.2rem;
+ --line-height-regular: 1.2rem;
+ --line-height-list: var(--line-height-regular);
+ --line-height-code: var(--line-height-regular);
+ }
+ /* end font sizes and line heights */
+
+ /* font styles */
+ &.sans-serif {
+ --font-style: "Inter", sans-serif;
+ }
+ &.serif {
+ --font-style: serif;
+ }
+ &.monospace {
+ --font-style: monospace;
+ }
+ /* end font styles */
+}
diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json
index 332e360804..335047356e 100644
--- a/packages/eslint-config/package.json
+++ b/packages/eslint-config/package.json
@@ -1,7 +1,7 @@
{
"name": "@plane/eslint-config",
"private": true,
- "version": "0.23.0",
+ "version": "0.23.1",
"files": [
"library.js",
"next.js",
diff --git a/packages/helpers/package.json b/packages/helpers/package.json
index c94c8f7634..b4b94db1f5 100644
--- a/packages/helpers/package.json
+++ b/packages/helpers/package.json
@@ -1,6 +1,6 @@
{
"name": "@plane/helpers",
- "version": "0.23.0",
+ "version": "0.23.1",
"description": "Helper functions shared across multiple apps internally",
"private": true,
"main": "./dist/index.js",
diff --git a/packages/tailwind-config-custom/package.json b/packages/tailwind-config-custom/package.json
index bf35a97dcc..cec6628a64 100644
--- a/packages/tailwind-config-custom/package.json
+++ b/packages/tailwind-config-custom/package.json
@@ -1,6 +1,6 @@
{
"name": "tailwind-config-custom",
- "version": "0.23.0",
+ "version": "0.23.1",
"description": "common tailwind configuration across monorepo",
"main": "index.js",
"private": true,
diff --git a/packages/types/package.json b/packages/types/package.json
index 5a7bd93d7d..5962ca25c9 100644
--- a/packages/types/package.json
+++ b/packages/types/package.json
@@ -1,6 +1,6 @@
{
"name": "@plane/types",
- "version": "0.23.0",
+ "version": "0.23.1",
"private": true,
"types": "./src/index.d.ts",
"main": "./src/index.d.ts"
diff --git a/packages/types/src/analytics.d.ts b/packages/types/src/analytics.d.ts
index 2fb7ad51a7..ec417e73fe 100644
--- a/packages/types/src/analytics.d.ts
+++ b/packages/types/src/analytics.d.ts
@@ -20,7 +20,7 @@ export interface IAnalyticsData {
}
export interface IAnalyticsAssigneeDetails {
- assignees__avatar: string | null;
+ assignees__avatar_url: string | null;
assignees__display_name: string | null;
assignees__first_name: string;
assignees__id: string | null;
@@ -87,7 +87,7 @@ export interface IExportAnalyticsFormData {
}
export interface IDefaultAnalyticsUser {
- assignees__avatar: string | null;
+ assignees__avatar_url: string | null;
assignees__first_name: string;
assignees__last_name: string;
assignees__display_name: string;
@@ -99,7 +99,7 @@ export interface IDefaultAnalyticsResponse {
issue_completed_month_wise: { month: number; count: number }[];
most_issue_closed_user: IDefaultAnalyticsUser[];
most_issue_created_user: {
- created_by__avatar: string | null;
+ created_by__avatar_url: string | null;
created_by__first_name: string;
created_by__last_name: string;
created_by__display_name: string;
diff --git a/packages/types/src/current-user/accounts.d.ts b/packages/types/src/current-user/accounts.d.ts
deleted file mode 100644
index d328f0529b..0000000000
--- a/packages/types/src/current-user/accounts.d.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-export type TCurrentUserAccount = {
- id: string | undefined;
-
- user: string | undefined;
-
- provider_account_id: string | undefined;
- provider: "google" | "github" | "gitlab" | string | undefined;
- access_token: string | undefined;
- access_token_expired_at: Date | undefined;
- refresh_token: string | undefined;
- refresh_token_expired_at: Date | undefined;
- last_connected_at: Date | undefined;
- metadata: object | undefined;
-
- created_at: Date | undefined;
- updated_at: Date | undefined;
-};
diff --git a/packages/types/src/current-user/index.ts b/packages/types/src/current-user/index.ts
index 43a43b9cd3..aeb49bbab1 100644
--- a/packages/types/src/current-user/index.ts
+++ b/packages/types/src/current-user/index.ts
@@ -1,3 +1 @@
-export * from "./user";
export * from "./profile";
-export * from "./accounts";
diff --git a/packages/types/src/current-user/user.d.ts b/packages/types/src/current-user/user.d.ts
deleted file mode 100644
index 9bc67b6cf3..0000000000
--- a/packages/types/src/current-user/user.d.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-export type TCurrentUser = {
- id: string | undefined;
- avatar: string | undefined;
- cover_image: string | undefined;
- date_joined: Date | undefined;
- display_name: string | undefined;
- email: string | undefined;
- first_name: string | undefined;
- last_name: string | undefined;
- is_active: boolean;
- is_bot: boolean;
- is_email_verified: boolean;
- is_managed: boolean;
- mobile_number: string | undefined;
- user_timezone: string | undefined;
- username: string | undefined;
- is_password_autoset: boolean;
-};
-
-export type TCurrentUserSettings = {
- id: string | undefined;
- email: string | undefined;
- workspace: {
- last_workspace_id: string | undefined;
- last_workspace_slug: string | undefined;
- fallback_workspace_id: string | undefined;
- fallback_workspace_slug: string | undefined;
- invites: number | undefined;
- };
-};
diff --git a/packages/types/src/cycle/cycle.d.ts b/packages/types/src/cycle/cycle.d.ts
index fdcffb52b3..1c2fa273aa 100644
--- a/packages/types/src/cycle/cycle.d.ts
+++ b/packages/types/src/cycle/cycle.d.ts
@@ -20,7 +20,7 @@ export type TCycleEstimateDistributionBase = {
export type TCycleAssigneesDistribution = {
assignee_id: string | null;
- avatar: string | null;
+ avatar_url: string | null;
first_name: string | null;
last_name: string | null;
display_name: string | null;
diff --git a/packages/types/src/enums.ts b/packages/types/src/enums.ts
index 914ebb0c3d..df6a462b02 100644
--- a/packages/types/src/enums.ts
+++ b/packages/types/src/enums.ts
@@ -48,3 +48,15 @@ export enum ENotificationFilterType {
ASSIGNED = "assigned",
SUBSCRIBED = "subscribed",
}
+
+export enum EFileAssetType {
+ COMMENT_DESCRIPTION = "COMMENT_DESCRIPTION",
+ ISSUE_ATTACHMENT = "ISSUE_ATTACHMENT",
+ ISSUE_DESCRIPTION = "ISSUE_DESCRIPTION",
+ DRAFT_ISSUE_DESCRIPTION = "DRAFT_ISSUE_DESCRIPTION",
+ PAGE_DESCRIPTION = "PAGE_DESCRIPTION",
+ PROJECT_COVER = "PROJECT_COVER",
+ USER_AVATAR = "USER_AVATAR",
+ USER_COVER = "USER_COVER",
+ WORKSPACE_LOGO = "WORKSPACE_LOGO",
+}
diff --git a/packages/types/src/file.d.ts b/packages/types/src/file.d.ts
new file mode 100644
index 0000000000..8bcaade6c0
--- /dev/null
+++ b/packages/types/src/file.d.ts
@@ -0,0 +1,32 @@
+import { EFileAssetType } from "./enums"
+
+export type TFileMetaDataLite = {
+ name: string;
+ // file size in bytes
+ size: number;
+ type: string;
+}
+
+export type TFileEntityInfo = {
+ entity_identifier: string;
+ entity_type: EFileAssetType;
+}
+
+export type TFileMetaData = TFileMetaDataLite & TFileEntityInfo;
+
+export type TFileSignedURLResponse = {
+ asset_id: string;
+ asset_url: string;
+ upload_data: {
+ url: string;
+ fields: {
+ "Content-Type": string;
+ key: string;
+ "x-amz-algorithm": string;
+ "x-amz-credential": string;
+ "x-amz-date": string;
+ policy: string;
+ "x-amz-signature": string;
+ };
+ };
+};
\ No newline at end of file
diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts
index 6dfddc6b63..d637b0102a 100644
--- a/packages/types/src/index.d.ts
+++ b/packages/types/src/index.d.ts
@@ -29,3 +29,5 @@ export * from "./pragmatic";
export * from "./publish";
export * from "./workspace-notifications";
export * from "./favorite";
+export * from "./file";
+export * from "./workspace-draft-issues/base";
diff --git a/packages/types/src/integration.d.ts b/packages/types/src/integration.d.ts
index bb76f9fc0c..e2561bd18f 100644
--- a/packages/types/src/integration.d.ts
+++ b/packages/types/src/integration.d.ts
@@ -1,7 +1,6 @@
// All the app integrations that are available
export interface IAppIntegration {
author: string;
- author: "";
avatar_url: string | null;
created_at: string;
created_by: string | null;
diff --git a/packages/types/src/issues/activity/base.d.ts b/packages/types/src/issues/activity/base.d.ts
index 82b881fd94..63f365d893 100644
--- a/packages/types/src/issues/activity/base.d.ts
+++ b/packages/types/src/issues/activity/base.d.ts
@@ -40,7 +40,7 @@ export type TIssueActivityUserDetail = {
id: string;
first_name: string;
last_name: string;
- avatar: string;
+ avatar_url: string;
is_bot: boolean;
display_name: string;
};
diff --git a/packages/types/src/issues/base.d.ts b/packages/types/src/issues/base.d.ts
index 8292c11164..05f679cce2 100644
--- a/packages/types/src/issues/base.d.ts
+++ b/packages/types/src/issues/base.d.ts
@@ -10,6 +10,7 @@ export * from "./issue_relation";
export * from "./issue_sub_issues";
export * from "./activity/base";
+
export type TLoader =
| "init-loader"
| "mutation"
diff --git a/packages/types/src/issues/issue.d.ts b/packages/types/src/issues/issue.d.ts
index 1584a3d16c..aacc28023b 100644
--- a/packages/types/src/issues/issue.d.ts
+++ b/packages/types/src/issues/issue.d.ts
@@ -45,7 +45,7 @@ export type TIssue = TBaseIssue & {
is_subscribed?: boolean;
parent?: Partial;
issue_reactions?: TIssueReaction[];
- issue_attachment?: TIssueAttachment[];
+ issue_attachments?: TIssueAttachment[];
issue_link?: TIssueLink[];
// tempId is used for optimistic updates. It is not a part of the API response.
tempId?: string;
diff --git a/packages/types/src/issues/issue_attachment.d.ts b/packages/types/src/issues/issue_attachment.d.ts
index 7c3819e004..2238fa4c76 100644
--- a/packages/types/src/issues/issue_attachment.d.ts
+++ b/packages/types/src/issues/issue_attachment.d.ts
@@ -1,17 +1,22 @@
+import { TFileSignedURLResponse } from "../file";
+
export type TIssueAttachment = {
id: string;
attributes: {
name: string;
size: number;
};
- asset: string;
+ asset_url: string;
issue_id: string;
-
- //need
+ // required
updated_at: string;
updated_by: string;
};
+export type TIssueAttachmentUploadResponse = TFileSignedURLResponse & {
+ attachment: TIssueAttachment
+};
+
export type TIssueAttachmentMap = {
[issue_id: string]: TIssueAttachment;
};
diff --git a/packages/types/src/module/modules.d.ts b/packages/types/src/module/modules.d.ts
index 6a5a092317..fa77a6a414 100644
--- a/packages/types/src/module/modules.d.ts
+++ b/packages/types/src/module/modules.d.ts
@@ -26,7 +26,7 @@ export type TModuleEstimateDistributionBase = {
export type TModuleAssigneesDistribution = {
assignee_id: string | null;
- avatar: string | null;
+ avatar_url: string | null;
first_name: string | null;
last_name: string | null;
display_name: string | null;
diff --git a/packages/types/src/project/projects.d.ts b/packages/types/src/project/projects.d.ts
index a46f490f16..75d6668b8a 100644
--- a/packages/types/src/project/projects.d.ts
+++ b/packages/types/src/project/projects.d.ts
@@ -18,7 +18,7 @@ export interface IProject {
close_in: number;
created_at: Date;
created_by: string;
- cover_image: string | null;
+ cover_image_url: string;
cycle_view: boolean;
issue_views_view: boolean;
module_view: boolean;
@@ -54,6 +54,7 @@ export interface IProject {
updated_by: string;
workspace: IWorkspace | string;
workspace_detail: IWorkspaceLite;
+ timezone: string;
}
export interface IProjectLite {
@@ -75,7 +76,7 @@ export interface IProjectMap {
export interface IProjectMemberLite {
id: string;
- member__avatar: string;
+ member__avatar_url: string;
member__display_name: string;
member_id: string;
}
diff --git a/packages/types/src/users.d.ts b/packages/types/src/users.d.ts
index 4d5db28f9c..0440ff05f9 100644
--- a/packages/types/src/users.d.ts
+++ b/packages/types/src/users.d.ts
@@ -3,17 +3,21 @@ import { TUserPermissions } from "./enums";
type TLoginMediums = "email" | "magic-code" | "github" | "gitlab" | "google";
-export interface IUser {
- id: string;
- avatar: string | null;
- cover_image: string | null;
- date_joined: string;
+
+export interface IUserLite {
+ avatar_url: string;
display_name: string;
- email: string;
+ email?: string;
first_name: string;
- last_name: string;
- is_active: boolean;
+ id: string;
is_bot: boolean;
+ last_name: string;
+}
+export interface IUser extends IUserLite {
+ cover_image_url: string | null;
+ date_joined: string;
+ email: string;
+ is_active: boolean;
is_email_verified: boolean;
is_password_autoset: boolean;
is_tour_completed: boolean;
@@ -86,15 +90,6 @@ export interface IUserTheme {
sidebarBackground: string | undefined;
}
-export interface IUserLite {
- avatar: string;
- display_name: string;
- email?: string;
- first_name: string;
- id: string;
- is_bot: boolean;
- last_name: string;
-}
export interface IUserMemberLite extends IUserLite {
email?: string;
@@ -158,13 +153,8 @@ export interface IUserProfileProjectSegregation {
id: string;
pending_issues: number;
}[];
- user_data: {
- avatar: string;
- cover_image: string | null;
+ user_data: Pick & {
date_joined: Date;
- display_name: string;
- first_name: string;
- last_name: string;
user_timezone: string;
};
}
diff --git a/packages/types/src/workspace-draft-issues/base.d.ts b/packages/types/src/workspace-draft-issues/base.d.ts
new file mode 100644
index 0000000000..8090a9cb79
--- /dev/null
+++ b/packages/types/src/workspace-draft-issues/base.d.ts
@@ -0,0 +1,63 @@
+import { TIssuePriorities } from "../issues";
+
+export type TWorkspaceDraftIssue = {
+ id: string;
+ name: string;
+ sort_order: number;
+
+ state_id: string | undefined;
+ priority: TIssuePriorities | undefined;
+ label_ids: string[];
+ assignee_ids: string[];
+ estimate_point: string | undefined;
+
+ project_id: string | undefined;
+ parent_id: string | undefined;
+ cycle_id: string | undefined;
+ module_ids: string[] | undefined;
+
+ start_date: string | undefined;
+ target_date: string | undefined;
+ completed_at: string | undefined;
+
+ created_at: string;
+ updated_at: string;
+ created_by: string;
+ updated_by: string;
+
+ is_draft: boolean;
+
+ type_id: string;
+};
+
+export type TWorkspaceDraftPaginationInfo = {
+ next_cursor: string | undefined;
+ prev_cursor: string | undefined;
+ next_page_results: boolean | undefined;
+ prev_page_results: boolean | undefined;
+ total_pages: number | undefined;
+ count: number | undefined; // current paginated results count
+ total_count: number | undefined; // total available results count
+ total_results: number | undefined;
+ results: T[] | undefined;
+ extra_stats: string | undefined;
+ grouped_by: string | undefined;
+ sub_grouped_by: string | undefined;
+};
+
+export type TWorkspaceDraftQueryParams = {
+ per_page: number;
+ cursor: string;
+};
+
+export type TWorkspaceDraftIssueLoader =
+ | "init-loader"
+ | "empty-state"
+ | "mutation"
+ | "pagination"
+ | "loaded"
+ | "create"
+ | "update"
+ | "delete"
+ | "move"
+ | undefined;
diff --git a/packages/types/src/workspace.d.ts b/packages/types/src/workspace.d.ts
index f72f52463e..412083c428 100644
--- a/packages/types/src/workspace.d.ts
+++ b/packages/types/src/workspace.d.ts
@@ -14,8 +14,7 @@ export interface IWorkspace {
readonly updated_at: Date;
name: string;
url: string;
- logo: string | null;
- slug: string;
+ logo_url: string | null;
readonly total_members: number;
readonly slug: string;
readonly created_by: string;
@@ -71,7 +70,7 @@ export interface IWorkspaceMember {
member: IUserLite;
role: TUserPermissions;
created_at?: string;
- avatar?: string;
+ avatar_url?: string;
email?: string;
first_name?: string;
last_name?: string;
@@ -92,6 +91,7 @@ export interface IWorkspaceMemberMe {
updated_by: string;
view_props: IWorkspaceViewProps;
workspace: string;
+ draft_issue_count: number;
}
export interface ILastActiveWorkspaceDetails {
diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json
index 5356628b73..f6b12920c7 100644
--- a/packages/typescript-config/package.json
+++ b/packages/typescript-config/package.json
@@ -1,6 +1,6 @@
{
"name": "@plane/typescript-config",
- "version": "0.23.0",
+ "version": "0.23.1",
"private": true,
"files": [
"base.json",
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 73a720c9fc..09019457ae 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -2,7 +2,7 @@
"name": "@plane/ui",
"description": "UI components shared across multiple apps internally",
"private": true,
- "version": "0.23.0",
+ "version": "0.23.1",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
diff --git a/packages/ui/src/header/helper.tsx b/packages/ui/src/header/helper.tsx
index b6d76f8c6d..13fee8b479 100644
--- a/packages/ui/src/header/helper.tsx
+++ b/packages/ui/src/header/helper.tsx
@@ -10,9 +10,11 @@ export interface IHeaderProperties {
}
export const headerStyle: IHeaderProperties = {
[EHeaderVariant.PRIMARY]:
- "relative flex w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 bg-custom-background-100 z-[18]",
- [EHeaderVariant.SECONDARY]: "!py-0 overflow-y-hidden border-b border-custom-border-200 justify-between bg-custom-background-100 z-[15]",
- [EHeaderVariant.TERNARY]: "flex flex-wrap justify-between py-2 border-b border-custom-border-200 gap-2 bg-custom-background-100 z-[12]",
+ "relative flex w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 bg-custom-sidebar-background-100 z-[18]",
+ [EHeaderVariant.SECONDARY]:
+ "!py-0 overflow-y-hidden border-b border-custom-border-200 justify-between bg-custom-background-100 z-[15]",
+ [EHeaderVariant.TERNARY]:
+ "flex flex-wrap justify-between py-2 border-b border-custom-border-200 gap-2 bg-custom-background-100 z-[12]",
};
export const minHeights: IHeaderProperties = {
[EHeaderVariant.PRIMARY]: "",
diff --git a/packages/ui/src/icons/index.ts b/packages/ui/src/icons/index.ts
index 69436c2e83..e857dc1a49 100644
--- a/packages/ui/src/icons/index.ts
+++ b/packages/ui/src/icons/index.ts
@@ -31,3 +31,4 @@ export * from "./favorite-folder-icon";
export * from "./planned-icon";
export * from "./in-progress-icon";
export * from "./done-icon";
+export * from "./pending-icon";
diff --git a/packages/ui/src/icons/pending-icon.tsx b/packages/ui/src/icons/pending-icon.tsx
new file mode 100644
index 0000000000..5269a22e2b
--- /dev/null
+++ b/packages/ui/src/icons/pending-icon.tsx
@@ -0,0 +1,27 @@
+import * as React from "react";
+
+import { ISvgIcons } from "./type";
+
+export const PendingState: React.FC = ({ width = "10", height = "11", className, color = "#455068" }) => (
+
+);
diff --git a/packages/ui/src/modals/constants.ts b/packages/ui/src/modals/constants.ts
index 0cb268fc88..fe72ef7aea 100644
--- a/packages/ui/src/modals/constants.ts
+++ b/packages/ui/src/modals/constants.ts
@@ -4,6 +4,9 @@ export enum EModalPosition {
}
export enum EModalWidth {
+ SM = "sm:max-w-sm",
+ MD = "sm:max-w-md",
+ LG = "sm:max-w-lg",
XL = "sm:max-w-xl",
XXL = "sm:max-w-2xl",
XXXL = "sm:max-w-3xl",
diff --git a/space/core/components/editor/lite-text-editor.tsx b/space/core/components/editor/lite-text-editor.tsx
index 186f44a101..4cd6d82e87 100644
--- a/space/core/components/editor/lite-text-editor.tsx
+++ b/space/core/components/editor/lite-text-editor.tsx
@@ -1,30 +1,31 @@
import React from "react";
// editor
-import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor";
+import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef, TNonColorEditorCommands } from "@plane/editor";
// components
import { IssueCommentToolbar } from "@/components/editor";
// helpers
import { cn } from "@/helpers/common.helper";
+import { getEditorFileHandlers } from "@/helpers/editor.helper";
import { isCommentEmpty } from "@/helpers/string.helper";
// hooks
import { useMention } from "@/hooks/use-mention";
-// services
-import fileService from "@/services/file.service";
interface LiteTextEditorWrapperProps extends Omit {
- workspaceSlug: string;
+ anchor: string;
workspaceId: string;
isSubmitting?: boolean;
showSubmitButton?: boolean;
+ uploadFile: (file: File) => Promise;
}
export const LiteTextEditor = React.forwardRef((props, ref) => {
const {
+ anchor,
containerClassName,
- workspaceSlug,
workspaceId,
isSubmitting = false,
showSubmitButton = true,
+ uploadFile,
...rest
} = props;
// use-mention
@@ -39,12 +40,11 @@ export const LiteTextEditor = React.forwardRef
{
if (isMutableRefObject(ref)) {
- ref.current?.executeMenuItemCommand(key);
+ ref.current?.executeMenuItemCommand({
+ itemKey: key as TNonColorEditorCommands,
+ });
}
}}
isSubmitting={isSubmitting}
diff --git a/space/core/components/editor/lite-text-read-only-editor.tsx b/space/core/components/editor/lite-text-read-only-editor.tsx
index 033b98ccd1..e12a46682e 100644
--- a/space/core/components/editor/lite-text-read-only-editor.tsx
+++ b/space/core/components/editor/lite-text-read-only-editor.tsx
@@ -3,18 +3,24 @@ import React from "react";
import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor, LiteTextReadOnlyEditorWithRef } from "@plane/editor";
// helpers
import { cn } from "@/helpers/common.helper";
+import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper";
// hooks
import { useMention } from "@/hooks/use-mention";
-type LiteTextReadOnlyEditorWrapperProps = Omit;
+type LiteTextReadOnlyEditorWrapperProps = Omit & {
+ anchor: string;
+};
export const LiteTextReadOnlyEditor = React.forwardRef(
- ({ ...props }, ref) => {
+ ({ anchor, ...props }, ref) => {
const { mentionHighlights } = useMention();
return (
;
+type RichTextReadOnlyEditorWrapperProps = Omit & {
+ anchor: string;
+};
export const RichTextReadOnlyEditor = React.forwardRef(
- ({ ...props }, ref) => {
+ ({ anchor, ...props }, ref) => {
const { mentionHighlights } = useMention();
return (
= (props) => {
.flat()
.forEach((item) => {
// Assert that editorRef.current is not null
- newActiveStates[item.key] = (editorRef.current as EditorRefApi).isMenuItemActive(item.key);
+ newActiveStates[item.key] = (editorRef.current as EditorRefApi).isMenuItemActive({
+ itemKey: item.key as TNonColorEditorCommands,
+ });
});
setActiveStates(newActiveStates);
}
diff --git a/space/core/components/issues/navbar/user-avatar.tsx b/space/core/components/issues/navbar/user-avatar.tsx
index 9c1f3311de..40339bb5c0 100644
--- a/space/core/components/issues/navbar/user-avatar.tsx
+++ b/space/core/components/issues/navbar/user-avatar.tsx
@@ -10,6 +10,7 @@ import { Popover, Transition } from "@headlessui/react";
import { Avatar, Button } from "@plane/ui";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
+import { getFileURL } from "@/helpers/file.helper";
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hooks
import { useUser } from "@/hooks/store";
@@ -66,7 +67,7 @@ export const UserAvatar: FC = observer(() => {
>
= observer((props) => {
const { anchor } = props;
+ // states
+ const [uploadedAssetIds, setUploadAssetIds] = useState([]);
// refs
const editorRef = useRef(null);
// store hooks
- const { peekId: issueId, addIssueComment } = useIssueDetails();
+ const { peekId: issueId, addIssueComment, uploadCommentAsset } = useIssueDetails();
const { data: currentUser } = useUser();
- const { workspaceSlug, workspace: workspaceID } = usePublish(anchor);
+ const { workspace: workspaceID } = usePublish(anchor);
// form info
const {
handleSubmit,
@@ -44,9 +49,15 @@ export const AddComment: React.FC = observer((props) => {
if (!anchor || !issueId || isSubmitting || !formData.comment_html) return;
await addIssueComment(anchor, issueId, formData)
- .then(() => {
+ .then(async (res) => {
reset(defaultValues);
editorRef.current?.clearEditor();
+ if (uploadedAssetIds.length > 0) {
+ await fileService.updateBulkAssetsUploadStatus(anchor, res.id, {
+ asset_ids: uploadedAssetIds,
+ });
+ setUploadAssetIds([]);
+ }
})
.catch(() =>
setToast({
@@ -69,8 +80,8 @@ export const AddComment: React.FC = observer((props) => {
onEnterKeyPress={(e) => {
if (currentUser) handleSubmit(onSubmit)(e);
}}
+ anchor={anchor}
workspaceId={workspaceID?.toString() ?? ""}
- workspaceSlug={workspaceSlug?.toString() ?? ""}
ref={editorRef}
id="peek-overview-add-comment"
initialValue={
@@ -81,6 +92,11 @@ export const AddComment: React.FC = observer((props) => {
onChange={(comment_json, comment_html) => onChange(comment_html)}
isSubmitting={isSubmitting}
placeholder="Add Comment..."
+ uploadFile={async (file) => {
+ const { asset_id } = await uploadCommentAsset(file, anchor);
+ setUploadAssetIds((prev) => [...prev, asset_id]);
+ return asset_id;
+ }}
/>
)}
/>
diff --git a/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx b/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx
index 47b506b965..1b228dfb3e 100644
--- a/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx
+++ b/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx
@@ -9,6 +9,7 @@ import { LiteTextEditor, LiteTextReadOnlyEditor } from "@/components/editor";
import { CommentReactions } from "@/components/issues/peek-overview";
// helpers
import { timeAgo } from "@/helpers/date-time.helper";
+import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useIssueDetails, usePublish, useUser } from "@/hooks/store";
import useIsInIframe from "@/hooks/use-is-in-iframe";
@@ -23,9 +24,9 @@ type Props = {
export const CommentCard: React.FC = observer((props) => {
const { anchor, comment } = props;
// store hooks
- const { peekId, deleteIssueComment, updateIssueComment } = useIssueDetails();
+ const { peekId, deleteIssueComment, updateIssueComment, uploadCommentAsset } = useIssueDetails();
const { data: currentUser } = useUser();
- const { workspaceSlug, workspace: workspaceID } = usePublish(anchor);
+ const { workspace: workspaceID } = usePublish(anchor);
const isInIframe = useIsInIframe();
// states
@@ -58,10 +59,10 @@ export const CommentCard: React.FC = observer((props) => {
return (
- {comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? (
+ {comment.actor_detail.avatar_url && comment.actor_detail.avatar_url !== "" ? (
// eslint-disable-next-line @next/next/no-img-element

= observer((props) => {
name="comment_html"
render={({ field: { onChange, value } }) => (
-
+
diff --git a/space/core/components/issues/peek-overview/issue-details.tsx b/space/core/components/issues/peek-overview/issue-details.tsx
index b47bfad68c..36bad2fadc 100644
--- a/space/core/components/issues/peek-overview/issue-details.tsx
+++ b/space/core/components/issues/peek-overview/issue-details.tsx
@@ -26,6 +26,7 @@ export const PeekOverviewIssueDetails: React.FC = observer((props) => {
{issueDetails.name}
{description !== "" && description !== "" && (
{
+ this.cancelSource = axios.CancelToken.source();
+ return this.post(url, data, {
+ headers: {
+ "Content-Type": "multipart/form-data",
+ },
+ cancelToken: this.cancelSource.token,
+ })
+ .then((response) => response?.data)
+ .catch((error) => {
+ if (axios.isCancel(error)) {
+ console.log(error.message);
+ } else {
+ throw error?.response?.data;
+ }
+ });
+ }
+
+ cancelUpload() {
+ this.cancelSource.cancel("Upload canceled");
+ }
+}
diff --git a/space/core/services/file.service.ts b/space/core/services/file.service.ts
index 9fe06cd364..168738804e 100644
--- a/space/core/services/file.service.ts
+++ b/space/core/services/file.service.ts
@@ -1,106 +1,100 @@
-import axios from "axios";
+// plane types
+import { TFileEntityInfo, TFileSignedURLResponse } from "@plane/types";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
+import { generateFileUploadPayload, getAssetIdFromUrl, getFileMetaDataForUpload } from "@/helpers/file.helper";
// services
import { APIService } from "@/services/api.service";
+import { FileUploadService } from "@/services/file-upload.service";
-class FileService extends APIService {
+export class FileService extends APIService {
private cancelSource: any;
+ fileUploadService: FileUploadService;
constructor() {
super(API_BASE_URL);
- this.uploadFile = this.uploadFile.bind(this);
- this.deleteImage = this.deleteImage.bind(this);
- this.restoreImage = this.restoreImage.bind(this);
this.cancelUpload = this.cancelUpload.bind(this);
+ // services
+ this.fileUploadService = new FileUploadService();
}
- async uploadFile(workspaceSlug: string, file: FormData): Promise {
- this.cancelSource = axios.CancelToken.source();
- return this.post(`/api/workspaces/${workspaceSlug}/file-assets/`, file, {
- headers: {
- "Content-Type": "multipart/form-data",
- },
- cancelToken: this.cancelSource.token,
- })
+ private async updateAssetUploadStatus(anchor: string, assetId: string): Promise {
+ return this.patch(`/api/public/assets/v2/anchor/${anchor}/${assetId}/`)
.then((response) => response?.data)
.catch((error) => {
- if (axios.isCancel(error)) {
- console.log(error.message);
- } else {
- console.log(error);
- throw error?.response?.data;
- }
+ throw error?.response?.data;
+ });
+ }
+
+ async updateBulkAssetsUploadStatus(
+ anchor: string,
+ entityId: string,
+ data: {
+ asset_ids: string[];
+ }
+ ): Promise {
+ return this.post(`/api/public/assets/v2/anchor/${anchor}/${entityId}/bulk/`, data)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async uploadAsset(anchor: string, data: TFileEntityInfo, file: File): Promise {
+ const fileMetaData = getFileMetaDataForUpload(file);
+ return this.post(`/api/public/assets/v2/anchor/${anchor}/`, {
+ ...data,
+ ...fileMetaData,
+ })
+ .then(async (response) => {
+ const signedURLResponse: TFileSignedURLResponse = response?.data;
+ const fileUploadPayload = generateFileUploadPayload(signedURLResponse, file);
+ await this.fileUploadService.uploadFile(signedURLResponse.upload_data.url, fileUploadPayload);
+ await this.updateAssetUploadStatus(anchor, signedURLResponse.asset_id);
+ return signedURLResponse;
+ })
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async deleteNewAsset(assetPath: string): Promise {
+ return this.delete(assetPath)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async deleteOldEditorAsset(workspaceId: string, src: string): Promise {
+ const assetKey = getAssetIdFromUrl(src);
+ return this.delete(`/api/workspaces/file-assets/${workspaceId}/${assetKey}/`)
+ .then((response) => response?.status)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async restoreNewAsset(workspaceSlug: string, src: string): Promise {
+ // remove the last slash and get the asset id
+ const assetId = getAssetIdFromUrl(src);
+ return this.post(`/api/public/assets/v2/workspaces/${workspaceSlug}/restore/${assetId}/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
+ });
+ }
+
+ async restoreOldEditorAsset(workspaceId: string, src: string): Promise {
+ const assetKey = getAssetIdFromUrl(src);
+ return this.post(`/api/workspaces/file-assets/${workspaceId}/${assetKey}/restore/`)
+ .then((response) => response?.data)
+ .catch((error) => {
+ throw error?.response?.data;
});
}
cancelUpload() {
this.cancelSource.cancel("Upload cancelled");
}
-
- getUploadFileFunction(workspaceSlug: string): (file: File) => Promise {
- return async (file: File) => {
- const formData = new FormData();
- formData.append("asset", file);
- formData.append("attributes", JSON.stringify({}));
-
- const data = await this.uploadFile(workspaceSlug, formData);
- return data.asset;
- };
- }
-
- getDeleteImageFunction(workspaceId: string) {
- return async (src: string) => {
- try {
- const assetUrlWithWorkspaceId = `${workspaceId}/${this.extractAssetIdFromUrl(src, workspaceId)}`;
- const data = await this.deleteImage(assetUrlWithWorkspaceId);
- return data;
- } catch (e) {
- console.error(e);
- }
- };
- }
-
- getRestoreImageFunction(workspaceId: string) {
- return async (src: string) => {
- try {
- const assetUrlWithWorkspaceId = `${workspaceId}/${this.extractAssetIdFromUrl(src, workspaceId)}`;
- const data = await this.restoreImage(assetUrlWithWorkspaceId);
- return data;
- } catch (e) {
- console.error(e);
- }
- };
- }
-
- extractAssetIdFromUrl(src: string, workspaceId: string): string {
- const indexWhereAssetIdStarts = src.indexOf(workspaceId) + workspaceId.length + 1;
- if (indexWhereAssetIdStarts === -1) {
- throw new Error("Workspace ID not found in source string");
- }
- const assetUrl = src.substring(indexWhereAssetIdStarts);
- return assetUrl;
- }
-
- async deleteImage(assetUrlWithWorkspaceId: string): Promise {
- return this.delete(`/api/workspaces/file-assets/${assetUrlWithWorkspaceId}/`)
- .then((response) => response?.status)
- .catch((error) => {
- throw error?.response?.data;
- });
- }
-
- async restoreImage(assetUrlWithWorkspaceId: string): Promise {
- return this.post(`/api/workspaces/file-assets/${assetUrlWithWorkspaceId}/restore/`, {
- "Content-Type": "application/json",
- })
- .then((response) => response?.status)
- .catch((error) => {
- throw error?.response?.data;
- });
- }
}
-
-const fileService = new FileService();
-
-export default fileService;
diff --git a/space/core/services/issue.service.ts b/space/core/services/issue.service.ts
index 2f19b4f080..b5ecb80778 100644
--- a/space/core/services/issue.service.ts
+++ b/space/core/services/issue.service.ts
@@ -2,7 +2,7 @@ import { API_BASE_URL } from "@/helpers/common.helper";
// services
import { APIService } from "@/services/api.service";
// types
-import { TIssuesResponse, IIssue } from "@/types/issue";
+import { Comment, TIssuesResponse, IIssue } from "@/types/issue";
class IssueService extends APIService {
constructor() {
@@ -83,7 +83,7 @@ class IssueService extends APIService {
});
}
- async createIssueComment(anchor: string, issueID: string, data: any): Promise {
+ async createIssueComment(anchor: string, issueID: string, data: any): Promise {
return this.post(`/api/public/anchor/${anchor}/issues/${issueID}/comments/`, data)
.then((response) => response?.data)
.catch((error) => {
diff --git a/space/core/store/issue-detail.store.ts b/space/core/store/issue-detail.store.ts
index 8b4710b17b..ee8a3031ed 100644
--- a/space/core/store/issue-detail.store.ts
+++ b/space/core/store/issue-detail.store.ts
@@ -3,12 +3,16 @@ import set from "lodash/set";
import { makeObservable, observable, action, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
import { v4 as uuidv4 } from "uuid";
+// plane types
+import { TFileSignedURLResponse } from "@plane/types";
+import { EFileAssetType } from "@plane/types/src/enums";
// services
+import { FileService } from "@/services/file.service";
import IssueService from "@/services/issue.service";
// store
import { CoreRootStore } from "@/store/root.store";
// types
-import { IIssue, IPeekMode, IVote } from "@/types/issue";
+import { Comment, IIssue, IPeekMode, IVote } from "@/types/issue";
export interface IIssueDetailStore {
loader: boolean;
@@ -28,9 +32,10 @@ export interface IIssueDetailStore {
// issue actions
fetchIssueDetails: (anchor: string, issueID: string) => void;
// comment actions
- addIssueComment: (anchor: string, issueID: string, data: any) => Promise;
+ addIssueComment: (anchor: string, issueID: string, data: any) => Promise;
updateIssueComment: (anchor: string, issueID: string, commentID: string, data: any) => Promise;
deleteIssueComment: (anchor: string, issueID: string, commentID: string) => void;
+ uploadCommentAsset: (file: File, anchor: string, commentID?: string) => Promise;
addCommentReaction: (anchor: string, issueID: string, commentID: string, reactionHex: string) => void;
removeCommentReaction: (anchor: string, issueID: string, commentID: string, reactionHex: string) => void;
// reaction actions
@@ -54,6 +59,7 @@ export class IssueDetailStore implements IIssueDetailStore {
rootStore: CoreRootStore;
// services
issueService: IssueService;
+ fileService: FileService;
constructor(_rootStore: CoreRootStore) {
makeObservable(this, {
@@ -72,6 +78,7 @@ export class IssueDetailStore implements IIssueDetailStore {
addIssueComment: action,
updateIssueComment: action,
deleteIssueComment: action,
+ uploadCommentAsset: action,
addCommentReaction: action,
removeCommentReaction: action,
// reaction actions
@@ -83,6 +90,7 @@ export class IssueDetailStore implements IIssueDetailStore {
});
this.rootStore = _rootStore;
this.issueService = new IssueService();
+ this.fileService = new FileService();
}
setPeekId = (issueID: string | null) => {
@@ -220,6 +228,23 @@ export class IssueDetailStore implements IIssueDetailStore {
}
};
+ uploadCommentAsset = async (file: File, anchor: string, commentID?: string) => {
+ try {
+ const res = await this.fileService.uploadAsset(
+ anchor,
+ {
+ entity_identifier: commentID ?? "",
+ entity_type: EFileAssetType.COMMENT_DESCRIPTION,
+ },
+ file
+ );
+ return res;
+ } catch (error) {
+ console.log("Error in uploading comment asset:", error);
+ throw new Error("Asset upload failed. Please try again later.");
+ }
+ };
+
addCommentReaction = async (anchor: string, issueID: string, commentID: string, reactionHex: string) => {
const newReaction = {
id: uuidv4(),
diff --git a/space/core/store/user.store.ts b/space/core/store/user.store.ts
index 33b2cbe60a..6616b10b09 100644
--- a/space/core/store/user.store.ts
+++ b/space/core/store/user.store.ts
@@ -79,7 +79,7 @@ export class UserStore implements IUserStore {
first_name: this.data?.first_name,
last_name: this.data?.last_name,
display_name: this.data?.display_name,
- avatar: this.data?.avatar || undefined,
+ avatar_url: this.data?.avatar_url || undefined,
is_bot: false,
};
}
diff --git a/space/core/types/issue.d.ts b/space/core/types/issue.d.ts
index 79c6257d5a..3041a188d0 100644
--- a/space/core/types/issue.d.ts
+++ b/space/core/types/issue.d.ts
@@ -139,7 +139,7 @@ export interface IIssueReaction {
}
export interface ActorDetail {
- avatar?: string;
+ avatar_url?: string;
display_name?: string;
first_name?: string;
is_bot?: boolean;
diff --git a/space/helpers/editor.helper.ts b/space/helpers/editor.helper.ts
new file mode 100644
index 0000000000..648e409e70
--- /dev/null
+++ b/space/helpers/editor.helper.ts
@@ -0,0 +1,82 @@
+// plane editor
+import { TFileHandler } from "@plane/editor";
+// constants
+import { MAX_FILE_SIZE } from "@/constants/common";
+// helpers
+import { getFileURL } from "@/helpers/file.helper";
+// services
+import { FileService } from "@/services/file.service";
+const fileService = new FileService();
+
+/**
+ * @description generate the file source using assetId
+ * @param {string} anchor
+ */
+export const getEditorAssetSrc = (anchor: string, assetId: string): string | undefined => {
+ const url = getFileURL(`/api/public/assets/v2/anchor/${anchor}/${assetId}/`);
+ return url;
+};
+
+type TArgs = {
+ anchor: string;
+ uploadFile: (file: File) => Promise;
+ workspaceId: string;
+};
+
+/**
+ * @description this function returns the file handler required by the editors
+ * @param {TArgs} args
+ */
+export const getEditorFileHandlers = (args: TArgs): TFileHandler => {
+ const { anchor, uploadFile, workspaceId } = args;
+
+ return {
+ getAssetSrc: (path) => {
+ if (!path) return "";
+ if (path?.startsWith("http")) {
+ return path;
+ } else {
+ return getEditorAssetSrc(anchor, path) ?? "";
+ }
+ },
+ upload: uploadFile,
+ delete: async (src: string) => {
+ if (src?.startsWith("http")) {
+ await fileService.deleteOldEditorAsset(workspaceId, src);
+ } else {
+ await fileService.deleteNewAsset(getEditorAssetSrc(anchor, src) ?? "");
+ }
+ },
+ restore: async (src: string) => {
+ if (src?.startsWith("http")) {
+ await fileService.restoreOldEditorAsset(workspaceId, src);
+ } else {
+ await fileService.restoreNewAsset(anchor, src);
+ }
+ },
+ cancel: fileService.cancelUpload,
+ validation: {
+ maxFileSize: MAX_FILE_SIZE,
+ },
+ };
+};
+
+/**
+ * @description this function returns the file handler required by the read-only editors
+ */
+export const getReadOnlyEditorFileHandlers = (
+ args: Pick
+): { getAssetSrc: TFileHandler["getAssetSrc"] } => {
+ const { anchor } = args;
+
+ return {
+ getAssetSrc: (path) => {
+ if (!path) return "";
+ if (path?.startsWith("http")) {
+ return path;
+ } else {
+ return getEditorAssetSrc(anchor, path) ?? "";
+ }
+ },
+ };
+};
diff --git a/space/helpers/file.helper.ts b/space/helpers/file.helper.ts
new file mode 100644
index 0000000000..b149ebc7cf
--- /dev/null
+++ b/space/helpers/file.helper.ts
@@ -0,0 +1,51 @@
+// plane types
+import { TFileMetaDataLite, TFileSignedURLResponse } from "@plane/types";
+// helpers
+import { API_BASE_URL } from "@/helpers/common.helper";
+
+/**
+ * @description from the provided signed URL response, generate a payload to be used to upload the file
+ * @param {TFileSignedURLResponse} signedURLResponse
+ * @param {File} file
+ * @returns {FormData} file upload request payload
+ */
+export const generateFileUploadPayload = (signedURLResponse: TFileSignedURLResponse, file: File): FormData => {
+ const formData = new FormData();
+ Object.entries(signedURLResponse.upload_data.fields).forEach(([key, value]) => formData.append(key, value));
+ formData.append("file", file);
+ return formData;
+};
+
+/**
+ * @description combine the file path with the base URL
+ * @param {string} path
+ * @returns {string} final URL with the base URL
+ */
+export const getFileURL = (path: string): string | undefined => {
+ if (!path) return undefined;
+ const isValidURL = path.startsWith("http");
+ if (isValidURL) return path;
+ return `${API_BASE_URL}${path}`;
+};
+
+/**
+ * @description returns the necessary file meta data to upload a file
+ * @param {File} file
+ * @returns {TFileMetaDataLite} payload with file info
+ */
+export const getFileMetaDataForUpload = (file: File): TFileMetaDataLite => ({
+ name: file.name,
+ size: file.size,
+ type: file.type,
+});
+
+/**
+ * @description this function returns the assetId from the asset source
+ * @param {string} src
+ * @returns {string} assetId
+ */
+export const getAssetIdFromUrl = (src: string): string => {
+ const sourcePaths = src.split("/");
+ const assetUrl = sourcePaths[sourcePaths.length - 1];
+ return assetUrl;
+};
diff --git a/space/helpers/string.helper.ts b/space/helpers/string.helper.ts
index 5c704c44c3..dc838596a6 100644
--- a/space/helpers/string.helper.ts
+++ b/space/helpers/string.helper.ts
@@ -78,3 +78,25 @@ export const isCommentEmpty = (comment: string | undefined): boolean => {
export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " ");
export const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
+
+/**
+ * @description
+ * This function test whether a URL is valid or not.
+ *
+ * It accepts URLs with or without the protocol.
+ * @param {string} url
+ * @returns {boolean}
+ * @example
+ * checkURLValidity("https://example.com") => true
+ * checkURLValidity("example.com") => true
+ * checkURLValidity("example") => false
+ */
+export const checkURLValidity = (url: string): boolean => {
+ if (!url) return false;
+
+ // regex to support complex query parameters and fragments
+ const urlPattern =
+ /^(https?:\/\/)?((([a-z\d-]+\.)*[a-z\d-]+\.[a-z]{2,6})|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}))(:\d+)?(\/[\w.-]*)*(\?[^#\s]*)?(#[\w-]*)?$/i;
+
+ return urlPattern.test(url);
+};
diff --git a/space/package.json b/space/package.json
index 4bd7dda492..fb6c27ed3f 100644
--- a/space/package.json
+++ b/space/package.json
@@ -1,6 +1,6 @@
{
"name": "space",
- "version": "0.23.0",
+ "version": "0.23.1",
"private": true,
"scripts": {
"dev": "turbo run develop",
@@ -28,7 +28,6 @@
"date-fns": "^3.6.0",
"dompurify": "^3.0.11",
"dotenv": "^16.3.1",
- "js-cookie": "^3.0.1",
"lodash": "^4.17.21",
"lowlight": "^2.9.0",
"lucide-react": "^0.378.0",
@@ -52,7 +51,6 @@
"@plane/eslint-config": "*",
"@plane/typescript-config": "*",
"@types/dompurify": "^3.0.5",
- "@types/js-cookie": "^3.0.3",
"@types/lodash": "^4.17.1",
"@types/node": "18.14.1",
"@types/nprogress": "^0.2.0",
diff --git a/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx b/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx
index 72dae40b1e..4edf41bbdb 100644
--- a/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx
+++ b/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx
@@ -16,7 +16,7 @@ export const WorkspaceActiveCycleHeader = observer(() => (
type="text"
link={
}
/>
}
diff --git a/web/app/[workspaceSlug]/(projects)/drafts/header.tsx b/web/app/[workspaceSlug]/(projects)/drafts/header.tsx
new file mode 100644
index 0000000000..f77e61c319
--- /dev/null
+++ b/web/app/[workspaceSlug]/(projects)/drafts/header.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import { useState } from "react";
+import { observer } from "mobx-react";
+import { PenSquare } from "lucide-react";
+// ui
+import { Breadcrumbs, Button, Header } from "@plane/ui";
+// components
+import { BreadcrumbLink, CountChip } from "@/components/common";
+import { CreateUpdateIssueModal } from "@/components/issues";
+// constants
+import { EIssuesStoreType } from "@/constants/issue";
+// hooks
+import { useProject, useUserPermissions, useWorkspaceDraftIssues } from "@/hooks/store";
+// plane-web
+import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
+
+export const WorkspaceDraftHeader = observer(() => {
+ // state
+ const [isDraftIssueModalOpen, setIsDraftIssueModalOpen] = useState(false);
+ // store hooks
+ const { allowPermissions } = useUserPermissions();
+ const { paginationInfo } = useWorkspaceDraftIssues();
+ const { joinedProjectIds } = useProject();
+ // check if user is authorized to create draft issue
+ const isAuthorizedUser = allowPermissions(
+ [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
+ EUserPermissionsLevel.WORKSPACE
+ );
+
+ return (
+ <>
+ setIsDraftIssueModalOpen(false)}
+ isDraft
+ />
+
+ >
+ );
+});
diff --git a/web/app/[workspaceSlug]/(projects)/drafts/layout.tsx b/web/app/[workspaceSlug]/(projects)/drafts/layout.tsx
new file mode 100644
index 0000000000..a5a647bfdb
--- /dev/null
+++ b/web/app/[workspaceSlug]/(projects)/drafts/layout.tsx
@@ -0,0 +1,13 @@
+"use client";
+
+import { AppHeader, ContentWrapper } from "@/components/core";
+import { WorkspaceDraftHeader } from "./header";
+
+export default function WorkspaceDraftLayout({ children }: { children: React.ReactNode }) {
+ return (
+ <>
+ } />
+ {children}
+ >
+ );
+}
diff --git a/web/app/[workspaceSlug]/(projects)/drafts/page.tsx b/web/app/[workspaceSlug]/(projects)/drafts/page.tsx
new file mode 100644
index 0000000000..f94fc872ae
--- /dev/null
+++ b/web/app/[workspaceSlug]/(projects)/drafts/page.tsx
@@ -0,0 +1,27 @@
+"use client";
+
+import { useParams } from "next/navigation";
+// components
+import { PageHead } from "@/components/core";
+import { WorkspaceDraftIssuesRoot } from "@/components/issues/workspace-draft";
+
+const WorkspaceDraftPage = () => {
+ // router
+ const { workspaceSlug: routeWorkspaceSlug } = useParams();
+ const pageTitle = "Workspace Draft";
+
+ // derived values
+ const workspaceSlug = (routeWorkspaceSlug as string) || undefined;
+
+ if (!workspaceSlug) return null;
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
+
+export default WorkspaceDraftPage;
diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx
index 27e33e2c2c..aa81ae5816 100644
--- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx
+++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx
@@ -29,7 +29,7 @@ export const CycleIssuesMobileHeader = () => {
const { getCycleById } = useCycle();
const layouts = [
{ key: "list", title: "List", icon: List },
- { key: "kanban", title: "Kanban", icon: Kanban },
+ { key: "kanban", title: "Board", icon: Kanban },
{ key: "calendar", title: "Calendar", icon: Calendar },
];
diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/header.tsx
index a543eca0be..e733317d2b 100644
--- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/header.tsx
+++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/header.tsx
@@ -8,7 +8,7 @@ import { RefreshCcw } from "lucide-react";
import { Breadcrumbs, Button, Intake, Header } from "@plane/ui";
// components
import { BreadcrumbLink, Logo } from "@/components/common";
-import { InboxIssueCreateEditModalRoot } from "@/components/inbox";
+import { InboxIssueCreateModalRoot } from "@/components/inbox";
// hooks
import { useProject, useProjectInbox, useUserPermissions } from "@/hooks/store";
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
@@ -69,12 +69,11 @@ export const ProjectInboxHeader: FC = observer(() => {
{currentProjectDetails?.inbox_view && workspaceSlug && projectId && isAuthorized ? (
-
setCreateIssueModal(false)}
- issue={undefined}
/>