[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:
parent
edf0ab8175
commit
8533eba07d
41 changed files with 947 additions and 257 deletions
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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()}>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./image-block";
|
||||
export * from "./image-node";
|
||||
export * from "./image-uploader";
|
||||
157
packages/editor/src/core/extensions/custom-image/custom-image.ts
Normal file
157
packages/editor/src/core/extensions/custom-image/custom-image.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./components";
|
||||
export * from "./custom-image";
|
||||
export * from "./read-only-custom-image";
|
||||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
export * from "./extension";
|
||||
export * from "./image-extension-without-props";
|
||||
export * from "./image-resize";
|
||||
export * from "./read-only-image";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -83,7 +83,6 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
|
|||
...(extensions ?? []),
|
||||
...DocumentEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
fileHandler,
|
||||
issueEmbedConfig: embedHandler?.issue,
|
||||
provider,
|
||||
userDetails: user,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
111
packages/editor/src/core/hooks/use-file-upload.ts
Normal file
111
packages/editor/src/core/hooks/use-file-upload.ts
Normal 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 };
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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")) &&
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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%);
|
||||
|
|
@ -136,6 +138,7 @@
|
|||
filter: brightness(90%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom gap cursor styles */
|
||||
.ProseMirror-gapcursor::after {
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
8
packages/helpers/tsconfig.json
Normal file
8
packages/helpers/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "tsconfig/react-library.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react"
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
}
|
||||
|
|
@ -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"])
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue