[WEB-2450] dev: custom image extension (#5585)

* fix: svg not supported in image uploads

* fix: svg image file error message fixed

* feat: add custom image node for uploads

* fix: combine two extensions

* fix: added new image extension to backend

* fix: type errors

* style: image drop node

* style: image resize handler

* fix: removed unused stuff

* fix: types of updateAttributes

* fix: image insertion at pos and loading effect added

* fix: resize image real time sync

* fix: drag drop menu

* feat: custom image component editor

* fix: reverted back styles

* fix: reverted back document info changes

* fix: css image css

* style: image selected and hover states

* refactor: custom image extension folder structure

* style: read-only image

* chore: remove file handler

* fix: fixed multi time file opener

* fix: editor readonly content set properly

* fix: old images not rendered as new ones

* fix: drop upload fixed

* chore: remove console logs

* fix: src of image node as dependency

* fix: helper library build fix

* fix: improved reflow/layout and fixed resizing

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
This commit is contained in:
M. Palanikannan 2024-09-16 19:36:20 +05:30 committed by GitHub
parent edf0ab8175
commit 8533eba07d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 947 additions and 257 deletions

View file

@ -4,20 +4,17 @@ import { SlashCommand } from "@/extensions";
// plane editor types
import { TIssueEmbedConfig } from "@/plane-editor/types";
// types
import { TExtensions, TFileHandler, TUserDetails } from "@/types";
import { TExtensions, TUserDetails } from "@/types";
type Props = {
disabledExtensions?: TExtensions[];
fileHandler: TFileHandler;
issueEmbedConfig: TIssueEmbedConfig | undefined;
provider: HocuspocusProvider;
userDetails: TUserDetails;
};
export const DocumentEditorAdditionalExtensions = (props: Props) => {
const { fileHandler } = props;
const extensions: Extensions = [SlashCommand(fileHandler.upload)];
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
const extensions: Extensions = [SlashCommand()];
return extensions;
};

View file

@ -1,7 +1,5 @@
import { FC, ReactNode } from "react";
import { Editor, EditorContent } from "@tiptap/react";
// extensions
import { ImageResizer } from "@/extensions/image";
interface EditorContentProps {
children?: ReactNode;
@ -16,7 +14,6 @@ export const EditorContentWrapper: FC<EditorContentProps> = (props) => {
return (
<div tabIndex={tabIndex} onFocus={() => editor?.chain().focus(undefined, { scrollIntoView: false }).run()}>
<EditorContent editor={editor} />
{editor?.isActive("image") && editor?.isEditable && <ImageResizer editor={editor} id={id} />}
{children}
</div>
);

View file

@ -8,10 +8,10 @@ import { SideMenuExtension, SlashCommand } from "@/extensions";
import { EditorRefApi, IRichTextEditor } from "@/types";
const RichTextEditor = (props: IRichTextEditor) => {
const { dragDropEnabled, fileHandler } = props;
const { dragDropEnabled } = props;
const getExtensions = useCallback(() => {
const extensions = [SlashCommand(fileHandler.upload)];
const extensions = [SlashCommand()];
extensions.push(
SideMenuExtension({
@ -21,7 +21,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
);
return extensions;
}, [dragDropEnabled, fileHandler.upload]);
}, [dragDropEnabled]);
return (
<EditorWrapper {...props} extensions={getExtensions()}>

View file

@ -101,7 +101,8 @@ export const BlockMenu = (props: BlockMenuProps) => {
icon: Copy,
key: "duplicate",
label: "Duplicate",
isDisabled: editor.state.selection.content().content.firstChild?.type.name === "image",
isDisabled:
editor.state.selection.content().content.firstChild?.type.name === "image" || editor.isActive("imageComponent"),
onClick: (e) => {
e.preventDefault();
e.stopPropagation();

View file

@ -23,7 +23,6 @@ import {
} from "lucide-react";
// helpers
import {
insertImageCommand,
insertTableCommand,
setText,
toggleBlockquote,
@ -43,7 +42,7 @@ import {
toggleUnderline,
} from "@/helpers/editor-commands";
// types
import { TEditorCommands, UploadImage } from "@/types";
import { TEditorCommands } from "@/types";
export interface EditorMenuItem {
key: TEditorCommands;
@ -189,16 +188,17 @@ export const TableItem = (editor: Editor): EditorMenuItem => ({
icon: TableIcon,
});
export const ImageItem = (editor: Editor, uploadFile: UploadImage) =>
export const ImageItem = (editor: Editor) =>
({
key: "image",
name: "Image",
isActive: () => editor?.isActive("image"),
command: (savedSelection: Selection | null) => insertImageCommand(editor, uploadFile, savedSelection),
command: (savedSelection: Selection | null) =>
editor?.commands.setImageUpload({ event: "insert", pos: savedSelection?.from }),
icon: ImageIcon,
}) as const;
export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImage) {
export function getEditorMenuItems(editor: Editor | null) {
if (!editor) {
return [];
}
@ -220,6 +220,6 @@ export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImag
NumberedListItem(editor),
QuoteItem(editor),
TableItem(editor),
ImageItem(editor, uploadFile),
ImageItem(editor),
];
}

View file

@ -11,6 +11,7 @@ import { CustomCodeInlineExtension } from "./code-inline";
import { CustomLinkExtension } from "./custom-link";
import { CustomHorizontalRule } from "./horizontal-rule";
import { ImageExtensionWithoutProps } from "./image";
import { CustomImageComponentWithoutProps } from "./image/image-component-without-props";
import { IssueWidgetWithoutProps } from "./issue-embed/issue-embed-without-props";
import { CustomMentionWithoutProps } from "./mentions/mentions-without-props";
import { CustomQuoteExtension } from "./quote";
@ -61,6 +62,7 @@ export const CoreEditorExtensionsWithoutProps = [
class: "rounded-md",
},
}),
CustomImageComponentWithoutProps(),
TiptapUnderline,
TextStyle,
TaskList.configure({

View file

@ -0,0 +1,128 @@
import React, { useRef, useState, useCallback, useLayoutEffect } from "react";
import { NodeSelection } from "@tiptap/pm/state";
// extensions
import { CustomImageNodeViewProps } from "@/extensions/custom-image";
// helpers
import { cn } from "@/helpers/common";
const MIN_SIZE = 100;
export const CustomImageBlock: React.FC<CustomImageNodeViewProps> = (props) => {
const { node, updateAttributes, selected, getPos, editor } = props;
const { src, width, height } = node.attrs;
const [size, setSize] = useState({ width: width || "35%", height: height || "auto" });
const [isLoading, setIsLoading] = useState(true);
const containerRef = useRef<HTMLDivElement>(null);
const containerRect = useRef<DOMRect | null>(null);
const imageRef = useRef<HTMLImageElement>(null);
const isResizing = useRef(false);
const aspectRatio = useRef(1);
useLayoutEffect(() => {
if (imageRef.current) {
const img = imageRef.current;
img.onload = () => {
if (node.attrs.width === "35%" && node.attrs.height === "auto") {
aspectRatio.current = img.naturalWidth / img.naturalHeight;
const initialWidth = Math.max(img.naturalWidth * 0.35, MIN_SIZE);
const initialHeight = initialWidth / aspectRatio.current;
setSize({ width: `${initialWidth}px`, height: `${initialHeight}px` });
}
setIsLoading(false);
};
}
}, [src]);
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
e.stopPropagation();
isResizing.current = true;
if (containerRef.current) {
containerRect.current = containerRef.current.getBoundingClientRect();
}
}, []);
useLayoutEffect(() => {
// for realtime resizing and undo/redo
setSize({ width, height });
}, [width, height]);
const handleResize = useCallback((e: MouseEvent | TouchEvent) => {
if (!isResizing.current || !containerRef.current || !containerRect.current) return;
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
const newWidth = Math.max(clientX - containerRect.current.left, MIN_SIZE);
const newHeight = newWidth / aspectRatio.current;
setSize({ width: `${newWidth}px`, height: `${newHeight}px` });
}, []);
const handleResizeEnd = useCallback(() => {
if (isResizing.current) {
isResizing.current = false;
updateAttributes(size);
}
}, [size, updateAttributes]);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
const pos = getPos();
const nodeSelection = NodeSelection.create(editor.state.doc, pos);
editor.view.dispatch(editor.state.tr.setSelection(nodeSelection));
},
[editor, getPos]
);
useLayoutEffect(() => {
const handleGlobalMouseMove = (e: MouseEvent) => handleResize(e);
const handleGlobalMouseUp = () => handleResizeEnd();
document.addEventListener("mousemove", handleGlobalMouseMove);
document.addEventListener("mouseup", handleGlobalMouseUp);
return () => {
document.removeEventListener("mousemove", handleGlobalMouseMove);
document.removeEventListener("mouseup", handleGlobalMouseUp);
};
}, [handleResize, handleResizeEnd]);
return (
<div
ref={containerRef}
className="group/image-component relative inline-block max-w-full"
onMouseDown={handleMouseDown}
style={{
width: size.width,
height: size.height,
}}
>
{isLoading && <div className="animate-pulse bg-custom-background-80 rounded-md" style={{ width, height }} />}
<img
ref={imageRef}
src={src}
className={cn("block rounded-md", {
hidden: isLoading,
"read-only-image": !editor.isEditable,
})}
style={{
width: size.width,
height: size.height,
}}
/>
{editor.isEditable && selected && <div className="absolute inset-0 size-full bg-custom-primary-500/30" />}
{editor.isEditable && (
<>
<div className="opacity-0 group-hover/image-component:opacity-100 absolute inset-0 border-2 border-custom-primary-100 pointer-events-none rounded-md transition-opacity duration-100 ease-in-out" />
<div
className="opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto absolute bottom-0 right-0 translate-y-1/2 translate-x-1/2 size-4 rounded-full bg-custom-primary-100 border-2 border-white cursor-nwse-resize transition-opacity duration-100 ease-in-out"
onMouseDown={handleResizeStart}
/>
</>
)}
</div>
);
};

View file

@ -0,0 +1,122 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
import { Editor, NodeViewWrapper } from "@tiptap/react";
// extensions
import {
CustomImageBlock,
CustomImageUploader,
UploadEntity,
UploadImageExtensionStorage,
} from "@/extensions/custom-image";
export type CustomImageNodeViewProps = {
getPos: () => number;
editor: Editor;
node: ProsemirrorNode & {
attrs: {
src: string;
width: string;
height: string;
};
};
updateAttributes: (attrs: Record<string, any>) => void;
selected: boolean;
};
export const CustomImageNode = (props: CustomImageNodeViewProps) => {
const { getPos, editor, node, updateAttributes, selected } = props;
const fileInputRef = useRef<HTMLInputElement>(null);
const hasTriggeredFilePickerRef = useRef(false);
const [isUploaded, setIsUploaded] = useState(!!node.attrs.src);
const id = node.attrs.id as string;
const editorStorage = editor.storage.imageComponent as UploadImageExtensionStorage | undefined;
const getUploadEntity = useCallback(
(): UploadEntity | undefined => editorStorage?.fileMap.get(id),
[editorStorage, id]
);
const onUpload = useCallback(
(url: string) => {
if (url) {
setIsUploaded(true);
// Update the node view's src attribute
updateAttributes({ src: url });
editorStorage?.fileMap.delete(id);
}
},
[editorStorage?.fileMap, id, updateAttributes]
);
const uploadFile = useCallback(
async (file: File) => {
try {
// @ts-expect-error - TODO: fix typings, and don't remove await from
// here for now
const url: string = await editor?.commands.uploadImage(file);
if (!url) {
throw new Error("Something went wrong while uploading the image");
}
onUpload(url);
} catch (error) {
console.error("Error uploading file:", error);
}
},
[editor.commands, onUpload]
);
useEffect(() => {
const uploadEntity = getUploadEntity();
if (uploadEntity) {
if (uploadEntity.event === "drop" && "file" in uploadEntity) {
uploadFile(uploadEntity.file);
} else if (uploadEntity.event === "insert" && fileInputRef.current && !hasTriggeredFilePickerRef.current) {
const entity = editorStorage?.fileMap.get(id);
if (entity && entity.hasOpenedFileInputOnce) return;
fileInputRef.current.click();
hasTriggeredFilePickerRef.current = true;
if (!entity) return;
editorStorage?.fileMap.set(id, { ...entity, hasOpenedFileInputOnce: true });
}
}
}, [getUploadEntity, uploadFile]);
useEffect(() => {
if (node.attrs.src) {
setIsUploaded(true);
}
}, [node.attrs.src]);
const existingFile = React.useMemo(() => {
const entity = getUploadEntity();
return entity && entity.event === "drop" ? entity.file : undefined;
}, [getUploadEntity]);
return (
<NodeViewWrapper>
<div className="p-0 mx-0 my-2" data-drag-handle>
{isUploaded ? (
<CustomImageBlock
editor={editor}
getPos={getPos}
node={node}
updateAttributes={updateAttributes}
selected={selected}
/>
) : (
<CustomImageUploader
onUpload={onUpload}
editor={editor}
fileInputRef={fileInputRef}
existingFile={existingFile}
selected={selected}
/>
)}
</div>
</NodeViewWrapper>
);
};

View file

@ -0,0 +1,90 @@
import { ChangeEvent, useCallback, useEffect, useRef } from "react";
import { Editor } from "@tiptap/core";
import { ImageIcon } from "lucide-react";
// helpers
import { cn } from "@/helpers/common";
// hooks
import { useUploader, useFileUpload, useDropZone } from "@/hooks/use-file-upload";
// plugins
import { isFileValid } from "@/plugins/image";
type RefType = React.RefObject<HTMLInputElement> | ((instance: HTMLInputElement | null) => void);
const assignRef = (ref: RefType, value: HTMLInputElement | null) => {
if (typeof ref === "function") {
ref(value);
} else if (ref && typeof ref === "object") {
(ref as React.MutableRefObject<HTMLInputElement | null>).current = value;
}
};
export const CustomImageUploader = (props: {
onUpload: (url: string) => void;
editor: Editor;
fileInputRef: RefType;
existingFile?: File;
selected: boolean;
}) => {
const { selected, onUpload, editor, fileInputRef, existingFile } = props;
const { loading, uploadFile } = useUploader({ onUpload, editor });
const { handleUploadClick, ref: internalRef } = useFileUpload();
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ uploader: uploadFile });
const localRef = useRef<HTMLInputElement | null>(null);
const onFileChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (isFileValid(file)) {
uploadFile(file);
}
}
},
[uploadFile]
);
useEffect(() => {
// no need to validate as the file is already validated before the drop onto
// the editor
if (existingFile) {
uploadFile(existingFile);
}
}, [existingFile, uploadFile]);
return (
<div
className={cn(
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 hover:text-custom-text-200 bg-custom-background-90 hover:bg-custom-background-80 border border-dashed border-custom-border-300 cursor-pointer transition-all duration-200 ease-in-out",
{
"bg-custom-background-80 text-custom-text-200": draggedInside,
},
{
"text-custom-primary-200 bg-custom-primary-100/10": selected,
}
)}
onDrop={onDrop}
onDragOver={onDragEnter}
onDragLeave={onDragLeave}
contentEditable={false}
onClick={handleUploadClick}
>
<ImageIcon className="size-4" />
<div className="text-base font-medium">
{loading ? "Uploading..." : draggedInside ? "Drop image here" : existingFile ? "Uploading..." : "Add an image"}
</div>
<input
className="size-0 overflow-hidden"
ref={(element) => {
localRef.current = element;
assignRef(fileInputRef, element);
assignRef(internalRef as RefType, element);
}}
hidden
type="file"
accept=".jpg,.jpeg,.png,.webp"
onChange={onFileChange}
/>
</div>
);
};

View file

@ -0,0 +1,3 @@
export * from "./image-block";
export * from "./image-node";
export * from "./image-uploader";

View file

@ -0,0 +1,157 @@
import { mergeAttributes } from "@tiptap/core";
import { Image } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { v4 as uuidv4 } from "uuid";
// extensions
import { CustomImageNode } from "@/extensions/custom-image";
// plugins
import { TrackImageDeletionPlugin, TrackImageRestorationPlugin, isFileValid } from "@/plugins/image";
// types
import { TFileHandler } from "@/types";
// helpers
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
imageComponent: {
setImageUpload: ({ file, pos, event }: { file?: File; pos?: number; event: "insert" | "drop" }) => ReturnType;
uploadImage: (file: File) => () => Promise<string> | undefined;
};
}
}
export interface UploadImageExtensionStorage {
fileMap: Map<string, UploadEntity>;
}
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };
export const CustomImageExtension = (props: TFileHandler) => {
const { upload, delete: deleteImage, restore: restoreImage } = props;
return Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
name: "imageComponent",
selectable: true,
group: "block",
atom: true,
draggable: true,
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
src: {
default: null,
},
height: {
default: "auto",
},
["id"]: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: "image-component",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["image-component", mergeAttributes(HTMLAttributes)];
},
onCreate(this) {
const imageSources = new Set<string>();
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),
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
};
},
addProseMirrorPlugins() {
return [
TrackImageDeletionPlugin(this.editor, deleteImage, this.name),
TrackImageRestorationPlugin(this.editor, restoreImage, this.name),
];
},
addStorage() {
return {
fileMap: new Map(),
deletedImageSet: new Map<string, boolean>(),
};
},
addCommands() {
return {
setImageUpload:
(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)) {
return false;
}
// generate a unique id for the image to keep track of dropped
// files' file data
const fileId = uuidv4();
if (props?.event === "drop" && props.file) {
(this.editor.storage.imageComponent as UploadImageExtensionStorage).fileMap.set(fileId, {
file: props.file,
event: props.event,
});
} else if (props.event === "insert") {
(this.editor.storage.imageComponent as UploadImageExtensionStorage).fileMap.set(fileId, {
event: props.event,
});
}
const attributes = {
id: fileId,
};
if (props.pos) {
return commands.insertContentAt(props.pos, {
type: this.name,
attrs: attributes,
});
}
return commands.insertContent({
type: this.name,
attrs: attributes,
});
},
uploadImage: (file: File) => async () => {
const fileUrl = await upload(file);
return fileUrl;
},
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});
};

View file

@ -0,0 +1,3 @@
export * from "./components";
export * from "./custom-image";
export * from "./read-only-custom-image";

View file

@ -0,0 +1,54 @@
import { mergeAttributes } from "@tiptap/core";
import { Image } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
// components
import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions/custom-image";
export const CustomReadOnlyImageExtension = () =>
Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
name: "imageComponent",
selectable: false,
group: "block",
atom: true,
draggable: false,
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
src: {
default: null,
},
height: {
default: "auto",
},
["id"]: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: "image-component",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["image-component", mergeAttributes(HTMLAttributes)];
},
addStorage() {
return {
fileMap: new Map(),
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});

View file

@ -1,11 +1,8 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "prosemirror-state";
// plugins
import { startImageUpload } from "@/plugins/image";
// types
import { UploadImage } from "@/types";
import { EditorView } from "prosemirror-view";
export const DropHandlerExtension = (uploadFile: UploadImage) =>
export const DropHandlerExtension = () =>
Extension.create({
name: "dropHandler",
priority: 1000,
@ -15,29 +12,52 @@ export const DropHandlerExtension = (uploadFile: UploadImage) =>
new Plugin({
key: new PluginKey("drop-handler-plugin"),
props: {
handlePaste: (view, event) => {
if (event.clipboardData && event.clipboardData.files && event.clipboardData.files[0]) {
handlePaste: (view: EditorView, event: ClipboardEvent) => {
if (event.clipboardData && event.clipboardData.files && event.clipboardData.files.length > 0) {
event.preventDefault();
const file = event.clipboardData.files[0];
const files = Array.from(event.clipboardData.files);
const imageFiles = files.filter((file) => file.type.startsWith("image"));
if (imageFiles.length > 0) {
const pos = view.state.selection.from;
startImageUpload(this.editor, file, view, pos, uploadFile);
imageFiles.forEach((file, index) => {
this.editor
.chain()
.focus()
.setImageUpload({ file, pos: pos + index, event: "drop" })
.run();
});
return true;
}
}
return false;
},
handleDrop: (view, event, _slice, moved) => {
if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) {
handleDrop: (view: EditorView, event: DragEvent, _slice: any, moved: boolean) => {
if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {
event.preventDefault();
const file = event.dataTransfer.files[0];
const files = Array.from(event.dataTransfer.files);
const imageFiles = files.filter((file) => file.type.startsWith("image"));
if (imageFiles.length > 0) {
const coordinates = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (coordinates) {
startImageUpload(this.editor, file, view, coordinates.pos - 1, uploadFile);
imageFiles.forEach((file, index) => {
setTimeout(() => {
this.editor
.chain()
.focus()
.setImageUpload({ file, pos: coordinates.pos + index, event: "drop" })
.run();
}, index * 100); // Slight delay between insertions
});
}
return true;
}
}
return false;
},
},

View file

@ -12,6 +12,7 @@ import {
CustomCodeInlineExtension,
CustomCodeMarkPlugin,
CustomHorizontalRule,
CustomImageExtension,
CustomKeymap,
CustomLinkExtension,
CustomMention,
@ -79,7 +80,7 @@ export const CoreEditorExtensions = ({
...(enableHistory ? {} : { history: false }),
}),
CustomQuoteExtension,
DropHandlerExtension(uploadFile),
DropHandlerExtension(),
CustomHorizontalRule.configure({
HTMLAttributes: {
class: "my-4 border-custom-border-400",
@ -104,6 +105,12 @@ export const CoreEditorExtensions = ({
class: "rounded-md",
},
}),
CustomImageExtension({
delete: deleteFile,
restore: restoreFile,
upload: uploadFile,
cancel: cancelUploadImage ?? (() => {}),
}),
TiptapUnderline,
TextStyle,
TaskList.configure({
@ -142,7 +149,7 @@ export const CoreEditorExtensions = ({
placeholder: ({ editor, node }) => {
if (node.type.name === "heading") return `Heading ${node.attrs.level}`;
if (editor.storage.image.uploadInProgress) return "";
// if (editor.storage.image.uploadInProgress) return "";
const shouldHidePlaceholder =
editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image");

View file

@ -1,37 +1,33 @@
import ImageExt from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
// helpers
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// plugins
import {
IMAGE_NODE_TYPE,
ImageExtensionStorage,
TrackImageDeletionPlugin,
TrackImageRestorationPlugin,
UploadImagesPlugin,
} from "@/plugins/image";
import { ImageExtensionStorage, TrackImageDeletionPlugin, TrackImageRestorationPlugin } from "@/plugins/image";
// types
import { DeleteImage, RestoreImage } from "@/types";
// extensions
import { CustomImageNode } from "@/extensions";
export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreImage, cancelUploadImage?: () => void) =>
ImageExt.extend<any, ImageExtensionStorage>({
addKeyboardShortcuts() {
return {
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", "image"),
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", "image"),
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
};
},
addProseMirrorPlugins() {
return [
UploadImagesPlugin(this.editor, cancelUploadImage),
TrackImageDeletionPlugin(this.editor, deleteImage),
TrackImageRestorationPlugin(this.editor, restoreImage),
TrackImageDeletionPlugin(this.editor, deleteImage, this.name),
TrackImageRestorationPlugin(this.editor, restoreImage, this.name),
];
},
onCreate(this) {
const imageSources = new Set<string>();
this.editor.state.doc.descendants((node) => {
if (node.type.name === IMAGE_NODE_TYPE) {
if (node.type.name === this.name) {
imageSources.add(node.attrs.src);
}
});
@ -64,4 +60,9 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm
},
};
},
// render custom image node
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});

View file

@ -0,0 +1,57 @@
import { mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { Image } from "@tiptap/extension-image";
// extensions
import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions";
export const CustomImageComponentWithoutProps = () =>
Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
name: "imageComponent",
selectable: true,
group: "block",
atom: true,
draggable: true,
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
src: {
default: null,
},
height: {
default: "auto",
},
["id"]: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: "image-component",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["image-component", mergeAttributes(HTMLAttributes)];
},
addStorage() {
return {
fileMap: new Map(),
deletedImageSet: new Map<string, boolean>(),
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});
export default CustomImageComponentWithoutProps;

View file

@ -1,4 +1,7 @@
import ImageExt from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomImageNode } from "@/extensions";
export const ImageExtensionWithoutProps = () =>
ImageExt.extend({
@ -13,4 +16,8 @@ export const ImageExtensionWithoutProps = () =>
},
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});

View file

@ -1,85 +0,0 @@
import { useState } from "react";
import { Editor } from "@tiptap/react";
import Moveable from "react-moveable";
type Props = {
editor: Editor;
id: string;
};
const getImageElement = (editorId: string): HTMLImageElement | null =>
document.querySelector(`#editor-container-${editorId}.active-editor .ProseMirror-selectednode`);
export const ImageResizer = (props: Props) => {
const { editor, id } = props;
// states
const [aspectRatio, setAspectRatio] = useState(1);
const updateMediaSize = () => {
const imageElement = getImageElement(id);
if (!imageElement) return;
const selection = editor.state.selection;
// Use the style width/height if available, otherwise fall back to the element's natural width/height
const width = imageElement.style.width
? Number(imageElement.style.width.replace("px", ""))
: imageElement.getAttribute("width");
const height = imageElement.style.height
? Number(imageElement.style.height.replace("px", ""))
: imageElement.getAttribute("height");
editor.commands.setImage({
src: imageElement.src,
width: width,
height: height,
} as any);
editor.commands.setNodeSelection(selection.from);
};
return (
<Moveable
target={getImageElement(id)}
container={null}
origin={false}
edge={false}
throttleDrag={0}
keepRatio
resizable
throttleResize={0}
onResizeStart={() => {
const imageElement = getImageElement(id);
if (imageElement) {
const originalWidth = Number(imageElement.width);
const originalHeight = Number(imageElement.height);
setAspectRatio(originalWidth / originalHeight);
}
}}
onResize={({ target, width, height, delta }) => {
if (delta[0] || delta[1]) {
let newWidth, newHeight;
if (delta[0]) {
// Width change detected
newWidth = Math.max(width, 100);
newHeight = newWidth / aspectRatio;
} else if (delta[1]) {
// Height change detected
newHeight = Math.max(height, 100);
newWidth = newHeight * aspectRatio;
}
target.style.width = `${newWidth}px`;
target.style.height = `${newHeight}px`;
}
}}
onResizeEnd={() => {
updateMediaSize();
}}
scalable
renderDirections={["se"]}
onScale={({ target, transform }) => {
target.style.transform = transform;
}}
/>
);
};

View file

@ -1,4 +1,3 @@
export * from "./extension";
export * from "./image-extension-without-props";
export * from "./image-resize";
export * from "./read-only-image";

View file

@ -1,4 +1,7 @@
import Image from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomImageNode } from "@/extensions";
export const ReadOnlyImageExtension = Image.extend({
addAttributes() {
@ -12,4 +15,7 @@ export const ReadOnlyImageExtension = Image.extend({
},
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});

View file

@ -1,5 +1,6 @@
export * from "./code";
export * from "./code-inline";
export * from "./custom-image";
export * from "./custom-link";
export * from "./custom-list-keymap";
export * from "./image";

View file

@ -19,6 +19,7 @@ import {
TableRow,
Table,
CustomMention,
CustomReadOnlyImageExtension,
} from "@/extensions";
// helpers
import { isValidHttpUrl } from "@/helpers/common";
@ -74,6 +75,7 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
class: "rounded-md",
},
}),
CustomReadOnlyImageExtension(),
TiptapUnderline,
TextStyle,
TaskList.configure({

View file

@ -3,7 +3,7 @@ import { Plugin, PluginKey } from "@tiptap/pm/state";
import { EditorView } from "@tiptap/pm/view";
// plugins
import { AIHandlePlugin } from "@/plugins/ai-handle";
import { DragHandlePlugin } from "@/plugins/drag-handle";
import { DragHandlePlugin, nodeDOMAtCoords } from "@/plugins/drag-handle";
type Props = {
aiEnabled: boolean;
@ -59,41 +59,6 @@ const absoluteRect = (node: Element) => {
};
};
const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
const elements = document.elementsFromPoint(coords.x, coords.y);
const generalSelectors = [
"li",
"p:not(:first-child)",
".code-block",
"blockquote",
"img",
"h1, h2, h3, h4, h5, h6",
"[data-type=horizontalRule]",
".table-wrapper",
".issue-embed",
].join(", ");
for (const elem of elements) {
if (elem.matches("p:first-child") && elem.parentElement?.matches(".ProseMirror")) {
return elem;
}
// if the element is a <p> tag that is the first child of a td or th
if (
(elem.matches("td > p:first-child") || elem.matches("th > p:first-child")) &&
elem?.textContent?.trim() !== ""
) {
return elem; // Return only if p tag is not empty in td or th
}
// apply general selector
if (elem.matches(generalSelectors)) {
return elem;
}
}
return null;
};
const SideMenu = (options: SideMenuPluginProps) => {
const { handlesConfig } = options;
const editorSideMenu: HTMLDivElement | null = document.createElement("div");

View file

@ -28,7 +28,6 @@ import {
toggleBulletList,
toggleOrderedList,
toggleTaskList,
insertImageCommand,
toggleHeadingOne,
toggleHeadingTwo,
toggleHeadingThree,
@ -37,7 +36,7 @@ import {
toggleHeadingSix,
} from "@/helpers/editor-commands";
// types
import { CommandProps, ISlashCommandItem, UploadImage } from "@/types";
import { CommandProps, ISlashCommandItem } from "@/types";
interface CommandItemProps {
key: string;
@ -63,7 +62,7 @@ const Command = Extension.create<SlashCommandOptions>({
const { selection } = editor.state;
const parentNode = selection.$from.node(selection.$from.depth);
const blockType = parentNode?.type?.name;
const blockType = parentNode.type.name;
if (blockType === "codeBlock") {
return false;
@ -89,7 +88,7 @@ const Command = Extension.create<SlashCommandOptions>({
});
const getSuggestionItems =
(uploadFile: UploadImage, additionalOptions?: Array<ISlashCommandItem>) =>
(additionalOptions?: Array<ISlashCommandItem>) =>
({ query }: { query: string }) => {
let slashCommands: ISlashCommandItem[] = [
{
@ -224,11 +223,11 @@ const getSuggestionItems =
{
key: "image",
title: "Image",
description: "Upload an image from your computer.",
searchTerms: ["img", "photo", "picture", "media"],
icon: <ImageIcon className="size-3.5" />,
description: "Insert an image",
searchTerms: ["img", "photo", "picture", "media", "upload"],
command: ({ editor, range }: CommandProps) => {
insertImageCommand(editor, uploadFile, null, range);
editor.chain().focus().deleteRange(range).setImageUpload({ event: "insert" }).run();
},
},
{
@ -415,10 +414,10 @@ const renderItems = () => {
};
};
export const SlashCommand = (uploadFile: UploadImage, additionalOptions?: Array<ISlashCommandItem>) =>
export const SlashCommand = (additionalOptions?: Array<ISlashCommandItem>) =>
Command.configure({
suggestion: {
items: getSuggestionItems(uploadFile, additionalOptions),
items: getSuggestionItems(additionalOptions),
render: renderItems,
},
});

View file

@ -83,7 +83,6 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
...(extensions ?? []),
...DocumentEditorAdditionalExtensions({
disabledExtensions,
fileHandler,
issueEmbedConfig: embedHandler?.issue,
provider,
userDetails: user,

View file

@ -91,6 +91,7 @@ export const useEditor = (props: CustomEditorProps) => {
onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),
onDestroy: () => handleEditorReady?.(false),
});
// Update the ref whenever savedSelection changes
useEffect(() => {
savedSelectionRef.current = savedSelection;
@ -123,7 +124,7 @@ export const useEditor = (props: CustomEditorProps) => {
editorRef.current?.commands.clearContent(emitUpdate);
},
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content);
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
},
setEditorValueAtCursorPosition: (content: string) => {
if (savedSelection) {
@ -131,7 +132,7 @@ export const useEditor = (props: CustomEditorProps) => {
}
},
executeMenuItemCommand: (itemKey: TEditorCommands) => {
const editorItems = getEditorMenuItems(editorRef.current, fileHandler.upload);
const editorItems = getEditorMenuItems(editorRef.current);
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
@ -147,7 +148,7 @@ export const useEditor = (props: CustomEditorProps) => {
}
},
isMenuItemActive: (itemName: TEditorCommands): boolean => {
const editorItems = getEditorMenuItems(editorRef.current, fileHandler.upload);
const editorItems = getEditorMenuItems(editorRef.current);
const getEditorMenuItem = (itemName: TEditorCommands) => editorItems.find((item) => item.key === itemName);
const item = getEditorMenuItem(itemName);
@ -214,20 +215,25 @@ export const useEditor = (props: CustomEditorProps) => {
}
});
const selection = nodesArray.join("");
console.log(selection);
return selection;
},
insertText: (contentHTML, insertOnNextLine) => {
if (!editor) return;
if (!editorRef.current) return;
// get selection
const { from, to, empty } = editor.state.selection;
const { from, to, empty } = editorRef.current.state.selection;
if (empty) return;
if (insertOnNextLine) {
// move cursor to the end of the selection and insert a new line
editor.chain().focus().setTextSelection(to).insertContent("<br />").insertContent(contentHTML).run();
editorRef.current
.chain()
.focus()
.setTextSelection(to)
.insertContent("<br />")
.insertContent(contentHTML)
.run();
} else {
// replace selected text with the content provided
editor.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
editorRef.current.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
}
},
getDocumentInfo: () => {
@ -238,7 +244,7 @@ export const useEditor = (props: CustomEditorProps) => {
};
},
}),
[editorRef, savedSelection, fileHandler.upload]
[editorRef, savedSelection]
);
if (!editor) {

View file

@ -0,0 +1,111 @@
import { DragEvent, useCallback, useEffect, useRef, useState } from "react";
import { Editor } from "@tiptap/core";
import { isFileValid } from "@/plugins/image";
export const useUploader = ({ onUpload, editor }: { onUpload: (url: string) => void; editor: Editor }) => {
const [loading, setLoading] = useState(false);
const uploadFile = useCallback(
async (file: File) => {
setLoading(true);
try {
// @ts-expect-error - TODO: fix typings, and don't remove await from
// here for now
const url: string = await editor?.commands.uploadImage(file);
if (!url) {
throw new Error("Something went wrong while uploading the image");
}
onUpload(url);
} catch (errPayload: any) {
console.log(errPayload);
const error = errPayload?.response?.data?.error || "Something went wrong";
console.error(error);
}
setLoading(false);
},
[onUpload, editor]
);
return { loading, uploadFile };
};
export const useFileUpload = () => {
const fileInput = useRef<HTMLInputElement>(null);
const handleUploadClick = useCallback(() => {
fileInput.current?.click();
}, []);
return { ref: fileInput, handleUploadClick };
};
export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) => {
const [isDragging, setIsDragging] = useState<boolean>(false);
const [draggedInside, setDraggedInside] = useState<boolean>(false);
useEffect(() => {
const dragStartHandler = () => {
setIsDragging(true);
};
const dragEndHandler = () => {
setIsDragging(false);
};
document.body.addEventListener("dragstart", dragStartHandler);
document.body.addEventListener("dragend", dragEndHandler);
return () => {
document.body.removeEventListener("dragstart", dragStartHandler);
document.body.removeEventListener("dragend", dragEndHandler);
};
}, []);
const onDrop = useCallback(
(e: DragEvent<HTMLDivElement>) => {
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) {
const isValid = isFileValid(file);
if (isValid) {
uploader(file);
}
}
},
[uploader]
);
const onDragEnter = () => {
setDraggedInside(true);
};
const onDragLeave = () => {
setDraggedInside(false);
};
return { isDragging, draggedInside, onDragEnter, onDragLeave, onDrop };
};

View file

@ -58,7 +58,7 @@ export const useReadOnlyEditor = ({
// for syncing swr data on tab refocus etc
useEffect(() => {
if (initialValue === null || initialValue === undefined) return;
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue);
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue, false, { preserveWhitespace: "full" });
}, [editor, initialValue]);
const editorRef: MutableRefObject<Editor | null> = useRef(null);
@ -68,7 +68,7 @@ export const useReadOnlyEditor = ({
editorRef.current?.commands.clearContent();
},
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content);
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
},
getMarkDown: (): string => {
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();

View file

@ -30,7 +30,7 @@ const createDragHandleElement = (): HTMLElement => {
return dragHandleElement;
};
const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
export const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
const elements = document.elementsFromPoint(coords.x, coords.y);
const generalSelectors = [
"li",
@ -42,13 +42,34 @@ const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
"[data-type=horizontalRule]",
".table-wrapper",
".issue-embed",
".image-upload-component",
].join(", ");
const hasNestedImg = (el: Element): boolean => {
if (el.tagName.toLowerCase() === "img") return true;
// @ts-expect-error todo
for (const child of el.children) {
if (hasNestedImg(child)) return true;
}
return false;
};
for (const elem of elements) {
const elemHasNestedImg = hasNestedImg(elem);
if (elem.matches("p:first-child") && elem.parentElement?.matches(".ProseMirror")) {
return elem;
}
// if the element is a <p> tag and has a nested img i.e. the new image
// component
if (elem.matches("p") && elemHasNestedImg) {
return null;
}
if (elem.matches("div") && elemHasNestedImg) {
return elem;
}
// if the element is a <p> tag that is the first child of a td or th
if (
(elem.matches("td > p:first-child") || elem.matches("th > p:first-child")) &&

View file

@ -1,17 +1,17 @@
import { Editor } from "@tiptap/core";
import { EditorState, Plugin, Transaction } from "@tiptap/pm/state";
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
// plugins
import { IMAGE_NODE_TYPE, deleteKey, type ImageNode } from "@/plugins/image";
import { type ImageNode } from "@/plugins/image";
// types
import { DeleteImage } from "@/types";
export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImage): Plugin =>
export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImage, nodeType: string): Plugin =>
new Plugin({
key: deleteKey,
key: new PluginKey(`delete-${nodeType}`),
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
const newImageSources = new Set<string>();
newState.doc.descendants((node) => {
if (node.type.name === IMAGE_NODE_TYPE) {
if (node.type.name === nodeType) {
newImageSources.add(node.attrs.src);
}
});
@ -25,7 +25,7 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag
// iterate through all the nodes in the old state
oldState.doc.descendants((oldNode) => {
// if the node is not an image, then return as no point in checking
if (oldNode.type.name !== IMAGE_NODE_TYPE) return;
if (oldNode.type.name !== nodeType) return;
// Check if the node has been deleted or replaced
if (!newImageSources.has(oldNode.attrs.src)) {
@ -35,7 +35,7 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag
removedImages.forEach(async (node) => {
const src = node.attrs.src;
editor.storage.image.deletedImageSet.set(src, true);
editor.storage[nodeType].deletedImageSet.set(src, true);
await onNodeDeleted(src, deleteImage);
});
});

View file

@ -1,17 +1,17 @@
import { Editor } from "@tiptap/core";
import { EditorState, Plugin, Transaction } from "@tiptap/pm/state";
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
// plugins
import { IMAGE_NODE_TYPE, ImageNode, restoreKey } from "@/plugins/image";
import { ImageNode } from "@/plugins/image";
// types
import { RestoreImage } from "@/types";
export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: RestoreImage): Plugin =>
export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: RestoreImage, nodeType: string): Plugin =>
new Plugin({
key: restoreKey,
key: new PluginKey(`restore-${nodeType}`),
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
const oldImageSources = new Set<string>();
oldState.doc.descendants((node) => {
if (node.type.name === IMAGE_NODE_TYPE) {
if (node.type.name === nodeType) {
oldImageSources.add(node.attrs.src);
}
});
@ -22,20 +22,21 @@ export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: Restor
const addedImages: ImageNode[] = [];
newState.doc.descendants((node, pos) => {
if (node.type.name !== IMAGE_NODE_TYPE) return;
if (node.type.name !== nodeType) return;
if (pos < 0 || pos > newState.doc.content.size) return;
if (oldImageSources.has(node.attrs.src)) return;
addedImages.push(node as ImageNode);
});
addedImages.forEach(async (image) => {
const wasDeleted = editor.storage.image.deletedImageSet.get(image.attrs.src);
const src = image.attrs.src;
const wasDeleted = editor.storage[nodeType].deletedImageSet.get(src);
if (wasDeleted === undefined) {
editor.storage.image.deletedImageSet.set(image.attrs.src, false);
editor.storage[nodeType].deletedImageSet.set(src, false);
} else if (wasDeleted === true) {
try {
await onNodeRestored(image.attrs.src, restoreImage);
editor.storage.image.deletedImageSet.set(image.attrs.src, false);
await onNodeRestored(src, restoreImage);
editor.storage[nodeType].deletedImageSet.set(src, false);
} catch (error) {
console.error("Error restoring image: ", error);
}

View file

@ -1,17 +1,23 @@
export function isFileValid(file: File): boolean {
export function isFileValid(file: File, showAlert = true): boolean {
if (!file) {
if (showAlert) {
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.");
}
return false;
}
if (file.size > 5 * 1024 * 1024) {
if (showAlert) {
alert("File size too large. Please select a file smaller than 5MB.");
}
return false;
}

View file

@ -22,7 +22,6 @@ export { isCellSelection } from "@/extensions/table/table/utilities/is-cell-sele
export * from "@/helpers/common";
export * from "@/helpers/editor-commands";
export * from "@/extensions/table/table";
export { startImageUpload } from "@/plugins/image";
// components
export * from "@/components/menus";

View file

@ -39,7 +39,7 @@
}
/* end ai handle */
.ProseMirror:not(.dragging) .ProseMirror-selectednode {
.ProseMirror:not(.dragging) .ProseMirror-selectednode:not(.node-imageComponent):not(.node-image) {
position: relative;
cursor: grab;
outline: none !important;
@ -63,6 +63,15 @@
border-radius: 4px;
pointer-events: none;
}
&.node-imageComponent,
&.node-image {
--horizontal-offset: 0px;
&::after {
background-color: rgba(var(--color-background-100), 0.2);
}
}
}
/* for targeting the task list items */
@ -96,7 +105,8 @@ ol > li:nth-child(n + 100).ProseMirror-selectednode:not(.dragging)::after {
margin-left: -35px;
}
.ProseMirror img {
.ProseMirror node-image,
.ProseMirror node-imageComponent {
transition: filter 0.1s ease-in-out;
cursor: pointer;

View file

@ -122,10 +122,12 @@
/* Custom image styles */
.ProseMirror img {
transition: filter 0.1s ease-in-out;
margin-top: 8px;
margin-top: 0 !important;
margin-bottom: 0;
&:not(.read-only-image) {
transition: filter 0.1s ease-in-out;
&:hover {
cursor: pointer;
filter: brightness(90%);
@ -135,6 +137,7 @@
outline: 3px solid rgba(var(--color-primary-100));
filter: brightness(90%);
}
}
}
/* Custom gap cursor styles */
@ -261,26 +264,6 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
transition: opacity 0.2s ease-out;
}
.img-placeholder {
position: relative;
width: 35%;
margin-top: 0 !important;
margin-bottom: 0 !important;
&::before {
content: "";
box-sizing: border-box;
position: absolute;
top: 50%;
left: 45%;
width: 20px;
height: 20px;
border-radius: 50%;
border: 3px solid rgba(var(--color-text-200));
border-top-color: rgba(var(--color-text-800));
animation: spinning 0.6s linear infinite;
}
}
@keyframes spinning {
to {

View file

@ -4,7 +4,7 @@ export default defineConfig((options: Options) => ({
entry: ["src/index.ts", "src/lib.ts"],
format: ["cjs", "esm"],
dts: true,
clean: true,
clean: false,
external: ["react"],
injectStyle: true,
...options,

View file

@ -2,12 +2,21 @@
"name": "@plane/helpers",
"version": "0.22.0",
"description": "Helper functions shared across multiple apps internally",
"main": "index.ts",
"private": true,
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": [
"dist/**"
],
"scripts": {
"build": "tsup ./index.ts --format esm,cjs --dts --external react --minify"
},
"devDependencies": {
"@types/node": "^22.5.4",
"@types/react": "^18.3.5",
"typescript": "^5.6.2"
"typescript": "^5.6.2",
"tsup": "^7.2.0"
},
"dependencies": {
"react": "^18.3.1"

View file

@ -0,0 +1,8 @@
{
"extends": "tsconfig/react-library.json",
"compilerOptions": {
"jsx": "react"
},
"include": ["."],
"exclude": ["dist", "build", "node_modules"]
}

View file

@ -69,7 +69,9 @@ export const isCommentEmpty = (comment: string | undefined): boolean => {
// return true if comment is undefined
if (!comment) return true;
return (
comment?.trim() === "" || comment === "<p></p>" || isEmptyHtmlString(comment ?? "", ["img", "mention-component"])
comment?.trim() === "" ||
comment === "<p></p>" ||
isEmptyHtmlString(comment ?? "", ["img", "mention-component", "image-component"])
);
};

View file

@ -249,7 +249,9 @@ export const isCommentEmpty = (comment: string | undefined): boolean => {
// return true if comment is undefined
if (!comment) return true;
return (
comment?.trim() === "" || comment === "<p></p>" || isEmptyHtmlString(comment ?? "", ["img", "mention-component"])
comment?.trim() === "" ||
comment === "<p></p>" ||
isEmptyHtmlString(comment ?? "", ["img", "mention-component", "image-component"])
);
};