[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
|
// plane editor types
|
||||||
import { TIssueEmbedConfig } from "@/plane-editor/types";
|
import { TIssueEmbedConfig } from "@/plane-editor/types";
|
||||||
// types
|
// types
|
||||||
import { TExtensions, TFileHandler, TUserDetails } from "@/types";
|
import { TExtensions, TUserDetails } from "@/types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
disabledExtensions?: TExtensions[];
|
disabledExtensions?: TExtensions[];
|
||||||
fileHandler: TFileHandler;
|
|
||||||
issueEmbedConfig: TIssueEmbedConfig | undefined;
|
issueEmbedConfig: TIssueEmbedConfig | undefined;
|
||||||
provider: HocuspocusProvider;
|
provider: HocuspocusProvider;
|
||||||
userDetails: TUserDetails;
|
userDetails: TUserDetails;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentEditorAdditionalExtensions = (props: Props) => {
|
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
|
||||||
const { fileHandler } = props;
|
const extensions: Extensions = [SlashCommand()];
|
||||||
|
|
||||||
const extensions: Extensions = [SlashCommand(fileHandler.upload)];
|
|
||||||
|
|
||||||
return extensions;
|
return extensions;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import { FC, ReactNode } from "react";
|
import { FC, ReactNode } from "react";
|
||||||
import { Editor, EditorContent } from "@tiptap/react";
|
import { Editor, EditorContent } from "@tiptap/react";
|
||||||
// extensions
|
|
||||||
import { ImageResizer } from "@/extensions/image";
|
|
||||||
|
|
||||||
interface EditorContentProps {
|
interface EditorContentProps {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
|
|
@ -16,7 +14,6 @@ export const EditorContentWrapper: FC<EditorContentProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<div tabIndex={tabIndex} onFocus={() => editor?.chain().focus(undefined, { scrollIntoView: false }).run()}>
|
<div tabIndex={tabIndex} onFocus={() => editor?.chain().focus(undefined, { scrollIntoView: false }).run()}>
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
{editor?.isActive("image") && editor?.isEditable && <ImageResizer editor={editor} id={id} />}
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,10 @@ import { SideMenuExtension, SlashCommand } from "@/extensions";
|
||||||
import { EditorRefApi, IRichTextEditor } from "@/types";
|
import { EditorRefApi, IRichTextEditor } from "@/types";
|
||||||
|
|
||||||
const RichTextEditor = (props: IRichTextEditor) => {
|
const RichTextEditor = (props: IRichTextEditor) => {
|
||||||
const { dragDropEnabled, fileHandler } = props;
|
const { dragDropEnabled } = props;
|
||||||
|
|
||||||
const getExtensions = useCallback(() => {
|
const getExtensions = useCallback(() => {
|
||||||
const extensions = [SlashCommand(fileHandler.upload)];
|
const extensions = [SlashCommand()];
|
||||||
|
|
||||||
extensions.push(
|
extensions.push(
|
||||||
SideMenuExtension({
|
SideMenuExtension({
|
||||||
|
|
@ -21,7 +21,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
|
||||||
);
|
);
|
||||||
|
|
||||||
return extensions;
|
return extensions;
|
||||||
}, [dragDropEnabled, fileHandler.upload]);
|
}, [dragDropEnabled]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EditorWrapper {...props} extensions={getExtensions()}>
|
<EditorWrapper {...props} extensions={getExtensions()}>
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,8 @@ export const BlockMenu = (props: BlockMenuProps) => {
|
||||||
icon: Copy,
|
icon: Copy,
|
||||||
key: "duplicate",
|
key: "duplicate",
|
||||||
label: "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) => {
|
onClick: (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
// helpers
|
// helpers
|
||||||
import {
|
import {
|
||||||
insertImageCommand,
|
|
||||||
insertTableCommand,
|
insertTableCommand,
|
||||||
setText,
|
setText,
|
||||||
toggleBlockquote,
|
toggleBlockquote,
|
||||||
|
|
@ -43,7 +42,7 @@ import {
|
||||||
toggleUnderline,
|
toggleUnderline,
|
||||||
} from "@/helpers/editor-commands";
|
} from "@/helpers/editor-commands";
|
||||||
// types
|
// types
|
||||||
import { TEditorCommands, UploadImage } from "@/types";
|
import { TEditorCommands } from "@/types";
|
||||||
|
|
||||||
export interface EditorMenuItem {
|
export interface EditorMenuItem {
|
||||||
key: TEditorCommands;
|
key: TEditorCommands;
|
||||||
|
|
@ -189,16 +188,17 @@ export const TableItem = (editor: Editor): EditorMenuItem => ({
|
||||||
icon: TableIcon,
|
icon: TableIcon,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ImageItem = (editor: Editor, uploadFile: UploadImage) =>
|
export const ImageItem = (editor: Editor) =>
|
||||||
({
|
({
|
||||||
key: "image",
|
key: "image",
|
||||||
name: "Image",
|
name: "Image",
|
||||||
isActive: () => editor?.isActive("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,
|
icon: ImageIcon,
|
||||||
}) as const;
|
}) as const;
|
||||||
|
|
||||||
export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImage) {
|
export function getEditorMenuItems(editor: Editor | null) {
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
@ -220,6 +220,6 @@ export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImag
|
||||||
NumberedListItem(editor),
|
NumberedListItem(editor),
|
||||||
QuoteItem(editor),
|
QuoteItem(editor),
|
||||||
TableItem(editor),
|
TableItem(editor),
|
||||||
ImageItem(editor, uploadFile),
|
ImageItem(editor),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { CustomCodeInlineExtension } from "./code-inline";
|
||||||
import { CustomLinkExtension } from "./custom-link";
|
import { CustomLinkExtension } from "./custom-link";
|
||||||
import { CustomHorizontalRule } from "./horizontal-rule";
|
import { CustomHorizontalRule } from "./horizontal-rule";
|
||||||
import { ImageExtensionWithoutProps } from "./image";
|
import { ImageExtensionWithoutProps } from "./image";
|
||||||
|
import { CustomImageComponentWithoutProps } from "./image/image-component-without-props";
|
||||||
import { IssueWidgetWithoutProps } from "./issue-embed/issue-embed-without-props";
|
import { IssueWidgetWithoutProps } from "./issue-embed/issue-embed-without-props";
|
||||||
import { CustomMentionWithoutProps } from "./mentions/mentions-without-props";
|
import { CustomMentionWithoutProps } from "./mentions/mentions-without-props";
|
||||||
import { CustomQuoteExtension } from "./quote";
|
import { CustomQuoteExtension } from "./quote";
|
||||||
|
|
@ -61,6 +62,7 @@ export const CoreEditorExtensionsWithoutProps = [
|
||||||
class: "rounded-md",
|
class: "rounded-md",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
CustomImageComponentWithoutProps(),
|
||||||
TiptapUnderline,
|
TiptapUnderline,
|
||||||
TextStyle,
|
TextStyle,
|
||||||
TaskList.configure({
|
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 { Extension } from "@tiptap/core";
|
||||||
import { Plugin, PluginKey } from "prosemirror-state";
|
import { Plugin, PluginKey } from "prosemirror-state";
|
||||||
// plugins
|
import { EditorView } from "prosemirror-view";
|
||||||
import { startImageUpload } from "@/plugins/image";
|
|
||||||
// types
|
|
||||||
import { UploadImage } from "@/types";
|
|
||||||
|
|
||||||
export const DropHandlerExtension = (uploadFile: UploadImage) =>
|
export const DropHandlerExtension = () =>
|
||||||
Extension.create({
|
Extension.create({
|
||||||
name: "dropHandler",
|
name: "dropHandler",
|
||||||
priority: 1000,
|
priority: 1000,
|
||||||
|
|
@ -15,29 +12,52 @@ export const DropHandlerExtension = (uploadFile: UploadImage) =>
|
||||||
new Plugin({
|
new Plugin({
|
||||||
key: new PluginKey("drop-handler-plugin"),
|
key: new PluginKey("drop-handler-plugin"),
|
||||||
props: {
|
props: {
|
||||||
handlePaste: (view, event) => {
|
handlePaste: (view: EditorView, event: ClipboardEvent) => {
|
||||||
if (event.clipboardData && event.clipboardData.files && event.clipboardData.files[0]) {
|
if (event.clipboardData && event.clipboardData.files && event.clipboardData.files.length > 0) {
|
||||||
event.preventDefault();
|
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;
|
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 true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
handleDrop: (view, event, _slice, moved) => {
|
handleDrop: (view: EditorView, event: DragEvent, _slice: any, moved: boolean) => {
|
||||||
if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) {
|
if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {
|
||||||
event.preventDefault();
|
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({
|
const coordinates = view.posAtCoords({
|
||||||
left: event.clientX,
|
left: event.clientX,
|
||||||
top: event.clientY,
|
top: event.clientY,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (coordinates) {
|
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 true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
CustomCodeInlineExtension,
|
CustomCodeInlineExtension,
|
||||||
CustomCodeMarkPlugin,
|
CustomCodeMarkPlugin,
|
||||||
CustomHorizontalRule,
|
CustomHorizontalRule,
|
||||||
|
CustomImageExtension,
|
||||||
CustomKeymap,
|
CustomKeymap,
|
||||||
CustomLinkExtension,
|
CustomLinkExtension,
|
||||||
CustomMention,
|
CustomMention,
|
||||||
|
|
@ -79,7 +80,7 @@ export const CoreEditorExtensions = ({
|
||||||
...(enableHistory ? {} : { history: false }),
|
...(enableHistory ? {} : { history: false }),
|
||||||
}),
|
}),
|
||||||
CustomQuoteExtension,
|
CustomQuoteExtension,
|
||||||
DropHandlerExtension(uploadFile),
|
DropHandlerExtension(),
|
||||||
CustomHorizontalRule.configure({
|
CustomHorizontalRule.configure({
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: "my-4 border-custom-border-400",
|
class: "my-4 border-custom-border-400",
|
||||||
|
|
@ -104,6 +105,12 @@ export const CoreEditorExtensions = ({
|
||||||
class: "rounded-md",
|
class: "rounded-md",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
CustomImageExtension({
|
||||||
|
delete: deleteFile,
|
||||||
|
restore: restoreFile,
|
||||||
|
upload: uploadFile,
|
||||||
|
cancel: cancelUploadImage ?? (() => {}),
|
||||||
|
}),
|
||||||
TiptapUnderline,
|
TiptapUnderline,
|
||||||
TextStyle,
|
TextStyle,
|
||||||
TaskList.configure({
|
TaskList.configure({
|
||||||
|
|
@ -142,7 +149,7 @@ export const CoreEditorExtensions = ({
|
||||||
placeholder: ({ editor, node }) => {
|
placeholder: ({ editor, node }) => {
|
||||||
if (node.type.name === "heading") return `Heading ${node.attrs.level}`;
|
if (node.type.name === "heading") return `Heading ${node.attrs.level}`;
|
||||||
|
|
||||||
if (editor.storage.image.uploadInProgress) return "";
|
// if (editor.storage.image.uploadInProgress) return "";
|
||||||
|
|
||||||
const shouldHidePlaceholder =
|
const shouldHidePlaceholder =
|
||||||
editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image");
|
editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image");
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,33 @@
|
||||||
import ImageExt from "@tiptap/extension-image";
|
import ImageExt from "@tiptap/extension-image";
|
||||||
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
// helpers
|
// helpers
|
||||||
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
||||||
// plugins
|
// plugins
|
||||||
import {
|
import { ImageExtensionStorage, TrackImageDeletionPlugin, TrackImageRestorationPlugin } from "@/plugins/image";
|
||||||
IMAGE_NODE_TYPE,
|
|
||||||
ImageExtensionStorage,
|
|
||||||
TrackImageDeletionPlugin,
|
|
||||||
TrackImageRestorationPlugin,
|
|
||||||
UploadImagesPlugin,
|
|
||||||
} from "@/plugins/image";
|
|
||||||
// types
|
// types
|
||||||
import { DeleteImage, RestoreImage } from "@/types";
|
import { DeleteImage, RestoreImage } from "@/types";
|
||||||
|
// extensions
|
||||||
|
import { CustomImageNode } from "@/extensions";
|
||||||
|
|
||||||
export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreImage, cancelUploadImage?: () => void) =>
|
export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreImage, cancelUploadImage?: () => void) =>
|
||||||
ImageExt.extend<any, ImageExtensionStorage>({
|
ImageExt.extend<any, ImageExtensionStorage>({
|
||||||
addKeyboardShortcuts() {
|
addKeyboardShortcuts() {
|
||||||
return {
|
return {
|
||||||
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", "image"),
|
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
|
||||||
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", "image"),
|
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
return [
|
return [
|
||||||
UploadImagesPlugin(this.editor, cancelUploadImage),
|
TrackImageDeletionPlugin(this.editor, deleteImage, this.name),
|
||||||
TrackImageDeletionPlugin(this.editor, deleteImage),
|
TrackImageRestorationPlugin(this.editor, restoreImage, this.name),
|
||||||
TrackImageRestorationPlugin(this.editor, restoreImage),
|
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
onCreate(this) {
|
onCreate(this) {
|
||||||
const imageSources = new Set<string>();
|
const imageSources = new Set<string>();
|
||||||
this.editor.state.doc.descendants((node) => {
|
this.editor.state.doc.descendants((node) => {
|
||||||
if (node.type.name === IMAGE_NODE_TYPE) {
|
if (node.type.name === this.name) {
|
||||||
imageSources.add(node.attrs.src);
|
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 ImageExt from "@tiptap/extension-image";
|
||||||
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
|
// extensions
|
||||||
|
import { CustomImageNode } from "@/extensions";
|
||||||
|
|
||||||
export const ImageExtensionWithoutProps = () =>
|
export const ImageExtensionWithoutProps = () =>
|
||||||
ImageExt.extend({
|
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 "./extension";
|
||||||
export * from "./image-extension-without-props";
|
export * from "./image-extension-without-props";
|
||||||
export * from "./image-resize";
|
|
||||||
export * from "./read-only-image";
|
export * from "./read-only-image";
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
import Image from "@tiptap/extension-image";
|
import Image from "@tiptap/extension-image";
|
||||||
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
|
// extensions
|
||||||
|
import { CustomImageNode } from "@/extensions";
|
||||||
|
|
||||||
export const ReadOnlyImageExtension = Image.extend({
|
export const ReadOnlyImageExtension = Image.extend({
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
|
|
@ -12,4 +15,7 @@ export const ReadOnlyImageExtension = Image.extend({
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(CustomImageNode);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export * from "./code";
|
export * from "./code";
|
||||||
export * from "./code-inline";
|
export * from "./code-inline";
|
||||||
|
export * from "./custom-image";
|
||||||
export * from "./custom-link";
|
export * from "./custom-link";
|
||||||
export * from "./custom-list-keymap";
|
export * from "./custom-list-keymap";
|
||||||
export * from "./image";
|
export * from "./image";
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
TableRow,
|
TableRow,
|
||||||
Table,
|
Table,
|
||||||
CustomMention,
|
CustomMention,
|
||||||
|
CustomReadOnlyImageExtension,
|
||||||
} from "@/extensions";
|
} from "@/extensions";
|
||||||
// helpers
|
// helpers
|
||||||
import { isValidHttpUrl } from "@/helpers/common";
|
import { isValidHttpUrl } from "@/helpers/common";
|
||||||
|
|
@ -74,6 +75,7 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
||||||
class: "rounded-md",
|
class: "rounded-md",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
CustomReadOnlyImageExtension(),
|
||||||
TiptapUnderline,
|
TiptapUnderline,
|
||||||
TextStyle,
|
TextStyle,
|
||||||
TaskList.configure({
|
TaskList.configure({
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||||
import { EditorView } from "@tiptap/pm/view";
|
import { EditorView } from "@tiptap/pm/view";
|
||||||
// plugins
|
// plugins
|
||||||
import { AIHandlePlugin } from "@/plugins/ai-handle";
|
import { AIHandlePlugin } from "@/plugins/ai-handle";
|
||||||
import { DragHandlePlugin } from "@/plugins/drag-handle";
|
import { DragHandlePlugin, nodeDOMAtCoords } from "@/plugins/drag-handle";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
aiEnabled: boolean;
|
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 SideMenu = (options: SideMenuPluginProps) => {
|
||||||
const { handlesConfig } = options;
|
const { handlesConfig } = options;
|
||||||
const editorSideMenu: HTMLDivElement | null = document.createElement("div");
|
const editorSideMenu: HTMLDivElement | null = document.createElement("div");
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ import {
|
||||||
toggleBulletList,
|
toggleBulletList,
|
||||||
toggleOrderedList,
|
toggleOrderedList,
|
||||||
toggleTaskList,
|
toggleTaskList,
|
||||||
insertImageCommand,
|
|
||||||
toggleHeadingOne,
|
toggleHeadingOne,
|
||||||
toggleHeadingTwo,
|
toggleHeadingTwo,
|
||||||
toggleHeadingThree,
|
toggleHeadingThree,
|
||||||
|
|
@ -37,7 +36,7 @@ import {
|
||||||
toggleHeadingSix,
|
toggleHeadingSix,
|
||||||
} from "@/helpers/editor-commands";
|
} from "@/helpers/editor-commands";
|
||||||
// types
|
// types
|
||||||
import { CommandProps, ISlashCommandItem, UploadImage } from "@/types";
|
import { CommandProps, ISlashCommandItem } from "@/types";
|
||||||
|
|
||||||
interface CommandItemProps {
|
interface CommandItemProps {
|
||||||
key: string;
|
key: string;
|
||||||
|
|
@ -63,7 +62,7 @@ const Command = Extension.create<SlashCommandOptions>({
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
|
|
||||||
const parentNode = selection.$from.node(selection.$from.depth);
|
const parentNode = selection.$from.node(selection.$from.depth);
|
||||||
const blockType = parentNode?.type?.name;
|
const blockType = parentNode.type.name;
|
||||||
|
|
||||||
if (blockType === "codeBlock") {
|
if (blockType === "codeBlock") {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -89,7 +88,7 @@ const Command = Extension.create<SlashCommandOptions>({
|
||||||
});
|
});
|
||||||
|
|
||||||
const getSuggestionItems =
|
const getSuggestionItems =
|
||||||
(uploadFile: UploadImage, additionalOptions?: Array<ISlashCommandItem>) =>
|
(additionalOptions?: Array<ISlashCommandItem>) =>
|
||||||
({ query }: { query: string }) => {
|
({ query }: { query: string }) => {
|
||||||
let slashCommands: ISlashCommandItem[] = [
|
let slashCommands: ISlashCommandItem[] = [
|
||||||
{
|
{
|
||||||
|
|
@ -224,11 +223,11 @@ const getSuggestionItems =
|
||||||
{
|
{
|
||||||
key: "image",
|
key: "image",
|
||||||
title: "Image",
|
title: "Image",
|
||||||
description: "Upload an image from your computer.",
|
|
||||||
searchTerms: ["img", "photo", "picture", "media"],
|
|
||||||
icon: <ImageIcon className="size-3.5" />,
|
icon: <ImageIcon className="size-3.5" />,
|
||||||
|
description: "Insert an image",
|
||||||
|
searchTerms: ["img", "photo", "picture", "media", "upload"],
|
||||||
command: ({ editor, range }: CommandProps) => {
|
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({
|
Command.configure({
|
||||||
suggestion: {
|
suggestion: {
|
||||||
items: getSuggestionItems(uploadFile, additionalOptions),
|
items: getSuggestionItems(additionalOptions),
|
||||||
render: renderItems,
|
render: renderItems,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,6 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
|
||||||
...(extensions ?? []),
|
...(extensions ?? []),
|
||||||
...DocumentEditorAdditionalExtensions({
|
...DocumentEditorAdditionalExtensions({
|
||||||
disabledExtensions,
|
disabledExtensions,
|
||||||
fileHandler,
|
|
||||||
issueEmbedConfig: embedHandler?.issue,
|
issueEmbedConfig: embedHandler?.issue,
|
||||||
provider,
|
provider,
|
||||||
userDetails: user,
|
userDetails: user,
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),
|
onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),
|
||||||
onDestroy: () => handleEditorReady?.(false),
|
onDestroy: () => handleEditorReady?.(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update the ref whenever savedSelection changes
|
// Update the ref whenever savedSelection changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
savedSelectionRef.current = savedSelection;
|
savedSelectionRef.current = savedSelection;
|
||||||
|
|
@ -123,7 +124,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
editorRef.current?.commands.clearContent(emitUpdate);
|
editorRef.current?.commands.clearContent(emitUpdate);
|
||||||
},
|
},
|
||||||
setEditorValue: (content: string) => {
|
setEditorValue: (content: string) => {
|
||||||
editorRef.current?.commands.setContent(content);
|
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
|
||||||
},
|
},
|
||||||
setEditorValueAtCursorPosition: (content: string) => {
|
setEditorValueAtCursorPosition: (content: string) => {
|
||||||
if (savedSelection) {
|
if (savedSelection) {
|
||||||
|
|
@ -131,7 +132,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
executeMenuItemCommand: (itemKey: TEditorCommands) => {
|
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);
|
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
|
||||||
|
|
||||||
|
|
@ -147,7 +148,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isMenuItemActive: (itemName: TEditorCommands): boolean => {
|
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 getEditorMenuItem = (itemName: TEditorCommands) => editorItems.find((item) => item.key === itemName);
|
||||||
const item = getEditorMenuItem(itemName);
|
const item = getEditorMenuItem(itemName);
|
||||||
|
|
@ -214,20 +215,25 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const selection = nodesArray.join("");
|
const selection = nodesArray.join("");
|
||||||
console.log(selection);
|
|
||||||
return selection;
|
return selection;
|
||||||
},
|
},
|
||||||
insertText: (contentHTML, insertOnNextLine) => {
|
insertText: (contentHTML, insertOnNextLine) => {
|
||||||
if (!editor) return;
|
if (!editorRef.current) return;
|
||||||
// get selection
|
// get selection
|
||||||
const { from, to, empty } = editor.state.selection;
|
const { from, to, empty } = editorRef.current.state.selection;
|
||||||
if (empty) return;
|
if (empty) return;
|
||||||
if (insertOnNextLine) {
|
if (insertOnNextLine) {
|
||||||
// move cursor to the end of the selection and insert a new line
|
// 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 {
|
} else {
|
||||||
// replace selected text with the content provided
|
// 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: () => {
|
getDocumentInfo: () => {
|
||||||
|
|
@ -238,7 +244,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[editorRef, savedSelection, fileHandler.upload]
|
[editorRef, savedSelection]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!editor) {
|
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
|
// for syncing swr data on tab refocus etc
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialValue === null || initialValue === undefined) return;
|
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]);
|
}, [editor, initialValue]);
|
||||||
|
|
||||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||||
|
|
@ -68,7 +68,7 @@ export const useReadOnlyEditor = ({
|
||||||
editorRef.current?.commands.clearContent();
|
editorRef.current?.commands.clearContent();
|
||||||
},
|
},
|
||||||
setEditorValue: (content: string) => {
|
setEditorValue: (content: string) => {
|
||||||
editorRef.current?.commands.setContent(content);
|
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
|
||||||
},
|
},
|
||||||
getMarkDown: (): string => {
|
getMarkDown: (): string => {
|
||||||
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
|
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ const createDragHandleElement = (): HTMLElement => {
|
||||||
return dragHandleElement;
|
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 elements = document.elementsFromPoint(coords.x, coords.y);
|
||||||
const generalSelectors = [
|
const generalSelectors = [
|
||||||
"li",
|
"li",
|
||||||
|
|
@ -42,13 +42,34 @@ const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
|
||||||
"[data-type=horizontalRule]",
|
"[data-type=horizontalRule]",
|
||||||
".table-wrapper",
|
".table-wrapper",
|
||||||
".issue-embed",
|
".issue-embed",
|
||||||
|
".image-upload-component",
|
||||||
].join(", ");
|
].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) {
|
for (const elem of elements) {
|
||||||
|
const elemHasNestedImg = hasNestedImg(elem);
|
||||||
if (elem.matches("p:first-child") && elem.parentElement?.matches(".ProseMirror")) {
|
if (elem.matches("p:first-child") && elem.parentElement?.matches(".ProseMirror")) {
|
||||||
return elem;
|
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 the element is a <p> tag that is the first child of a td or th
|
||||||
if (
|
if (
|
||||||
(elem.matches("td > p:first-child") || elem.matches("th > p:first-child")) &&
|
(elem.matches("td > p:first-child") || elem.matches("th > p:first-child")) &&
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
import { Editor } from "@tiptap/core";
|
import { Editor } from "@tiptap/core";
|
||||||
import { EditorState, Plugin, Transaction } from "@tiptap/pm/state";
|
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
|
||||||
// plugins
|
// plugins
|
||||||
import { IMAGE_NODE_TYPE, deleteKey, type ImageNode } from "@/plugins/image";
|
import { type ImageNode } from "@/plugins/image";
|
||||||
// types
|
// types
|
||||||
import { DeleteImage } from "@/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({
|
new Plugin({
|
||||||
key: deleteKey,
|
key: new PluginKey(`delete-${nodeType}`),
|
||||||
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
|
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
|
||||||
const newImageSources = new Set<string>();
|
const newImageSources = new Set<string>();
|
||||||
newState.doc.descendants((node) => {
|
newState.doc.descendants((node) => {
|
||||||
if (node.type.name === IMAGE_NODE_TYPE) {
|
if (node.type.name === nodeType) {
|
||||||
newImageSources.add(node.attrs.src);
|
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
|
// iterate through all the nodes in the old state
|
||||||
oldState.doc.descendants((oldNode) => {
|
oldState.doc.descendants((oldNode) => {
|
||||||
// if the node is not an image, then return as no point in checking
|
// 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
|
// Check if the node has been deleted or replaced
|
||||||
if (!newImageSources.has(oldNode.attrs.src)) {
|
if (!newImageSources.has(oldNode.attrs.src)) {
|
||||||
|
|
@ -35,7 +35,7 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag
|
||||||
|
|
||||||
removedImages.forEach(async (node) => {
|
removedImages.forEach(async (node) => {
|
||||||
const src = node.attrs.src;
|
const src = node.attrs.src;
|
||||||
editor.storage.image.deletedImageSet.set(src, true);
|
editor.storage[nodeType].deletedImageSet.set(src, true);
|
||||||
await onNodeDeleted(src, deleteImage);
|
await onNodeDeleted(src, deleteImage);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
import { Editor } from "@tiptap/core";
|
import { Editor } from "@tiptap/core";
|
||||||
import { EditorState, Plugin, Transaction } from "@tiptap/pm/state";
|
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
|
||||||
// plugins
|
// plugins
|
||||||
import { IMAGE_NODE_TYPE, ImageNode, restoreKey } from "@/plugins/image";
|
import { ImageNode } from "@/plugins/image";
|
||||||
// types
|
// types
|
||||||
import { RestoreImage } from "@/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({
|
new Plugin({
|
||||||
key: restoreKey,
|
key: new PluginKey(`restore-${nodeType}`),
|
||||||
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
|
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
|
||||||
const oldImageSources = new Set<string>();
|
const oldImageSources = new Set<string>();
|
||||||
oldState.doc.descendants((node) => {
|
oldState.doc.descendants((node) => {
|
||||||
if (node.type.name === IMAGE_NODE_TYPE) {
|
if (node.type.name === nodeType) {
|
||||||
oldImageSources.add(node.attrs.src);
|
oldImageSources.add(node.attrs.src);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -22,20 +22,21 @@ export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: Restor
|
||||||
const addedImages: ImageNode[] = [];
|
const addedImages: ImageNode[] = [];
|
||||||
|
|
||||||
newState.doc.descendants((node, pos) => {
|
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 (pos < 0 || pos > newState.doc.content.size) return;
|
||||||
if (oldImageSources.has(node.attrs.src)) return;
|
if (oldImageSources.has(node.attrs.src)) return;
|
||||||
addedImages.push(node as ImageNode);
|
addedImages.push(node as ImageNode);
|
||||||
});
|
});
|
||||||
|
|
||||||
addedImages.forEach(async (image) => {
|
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) {
|
if (wasDeleted === undefined) {
|
||||||
editor.storage.image.deletedImageSet.set(image.attrs.src, false);
|
editor.storage[nodeType].deletedImageSet.set(src, false);
|
||||||
} else if (wasDeleted === true) {
|
} else if (wasDeleted === true) {
|
||||||
try {
|
try {
|
||||||
await onNodeRestored(image.attrs.src, restoreImage);
|
await onNodeRestored(src, restoreImage);
|
||||||
editor.storage.image.deletedImageSet.set(image.attrs.src, false);
|
editor.storage[nodeType].deletedImageSet.set(src, false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error restoring image: ", 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 (!file) {
|
||||||
|
if (showAlert) {
|
||||||
alert("No file selected. Please select a file to upload.");
|
alert("No file selected. Please select a file to upload.");
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowedTypes = ["image/jpeg", "image/jpg", "image/png", "image/webp"];
|
const allowedTypes = ["image/jpeg", "image/jpg", "image/png", "image/webp"];
|
||||||
if (!allowedTypes.includes(file.type)) {
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
if (showAlert) {
|
||||||
alert("Invalid file type. Please select a JPEG, JPG, PNG, or WEBP image file.");
|
alert("Invalid file type. Please select a JPEG, JPG, PNG, or WEBP image file.");
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.size > 5 * 1024 * 1024) {
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
if (showAlert) {
|
||||||
alert("File size too large. Please select a file smaller than 5MB.");
|
alert("File size too large. Please select a file smaller than 5MB.");
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ export { isCellSelection } from "@/extensions/table/table/utilities/is-cell-sele
|
||||||
export * from "@/helpers/common";
|
export * from "@/helpers/common";
|
||||||
export * from "@/helpers/editor-commands";
|
export * from "@/helpers/editor-commands";
|
||||||
export * from "@/extensions/table/table";
|
export * from "@/extensions/table/table";
|
||||||
export { startImageUpload } from "@/plugins/image";
|
|
||||||
|
|
||||||
// components
|
// components
|
||||||
export * from "@/components/menus";
|
export * from "@/components/menus";
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@
|
||||||
}
|
}
|
||||||
/* end ai handle */
|
/* end ai handle */
|
||||||
|
|
||||||
.ProseMirror:not(.dragging) .ProseMirror-selectednode {
|
.ProseMirror:not(.dragging) .ProseMirror-selectednode:not(.node-imageComponent):not(.node-image) {
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
outline: none !important;
|
outline: none !important;
|
||||||
|
|
@ -63,6 +63,15 @@
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
pointer-events: none;
|
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 */
|
/* for targeting the task list items */
|
||||||
|
|
@ -96,7 +105,8 @@ ol > li:nth-child(n + 100).ProseMirror-selectednode:not(.dragging)::after {
|
||||||
margin-left: -35px;
|
margin-left: -35px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror img {
|
.ProseMirror node-image,
|
||||||
|
.ProseMirror node-imageComponent {
|
||||||
transition: filter 0.1s ease-in-out;
|
transition: filter 0.1s ease-in-out;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -122,10 +122,12 @@
|
||||||
|
|
||||||
/* Custom image styles */
|
/* Custom image styles */
|
||||||
.ProseMirror img {
|
.ProseMirror img {
|
||||||
transition: filter 0.1s ease-in-out;
|
margin-top: 0 !important;
|
||||||
margin-top: 8px;
|
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
&:not(.read-only-image) {
|
||||||
|
transition: filter 0.1s ease-in-out;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
filter: brightness(90%);
|
filter: brightness(90%);
|
||||||
|
|
@ -135,6 +137,7 @@
|
||||||
outline: 3px solid rgba(var(--color-primary-100));
|
outline: 3px solid rgba(var(--color-primary-100));
|
||||||
filter: brightness(90%);
|
filter: brightness(90%);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom gap cursor styles */
|
/* Custom gap cursor styles */
|
||||||
|
|
@ -261,26 +264,6 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
|
||||||
transition: opacity 0.2s ease-out;
|
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 {
|
@keyframes spinning {
|
||||||
to {
|
to {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ export default defineConfig((options: Options) => ({
|
||||||
entry: ["src/index.ts", "src/lib.ts"],
|
entry: ["src/index.ts", "src/lib.ts"],
|
||||||
format: ["cjs", "esm"],
|
format: ["cjs", "esm"],
|
||||||
dts: true,
|
dts: true,
|
||||||
clean: true,
|
clean: false,
|
||||||
external: ["react"],
|
external: ["react"],
|
||||||
injectStyle: true,
|
injectStyle: true,
|
||||||
...options,
|
...options,
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,21 @@
|
||||||
"name": "@plane/helpers",
|
"name": "@plane/helpers",
|
||||||
"version": "0.22.0",
|
"version": "0.22.0",
|
||||||
"description": "Helper functions shared across multiple apps internally",
|
"description": "Helper functions shared across multiple apps internally",
|
||||||
"main": "index.ts",
|
|
||||||
"private": true,
|
"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": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.5.4",
|
"@types/node": "^22.5.4",
|
||||||
"@types/react": "^18.3.5",
|
"@types/react": "^18.3.5",
|
||||||
"typescript": "^5.6.2"
|
"typescript": "^5.6.2",
|
||||||
|
"tsup": "^7.2.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.3.1"
|
"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
|
// return true if comment is undefined
|
||||||
if (!comment) return true;
|
if (!comment) return true;
|
||||||
return (
|
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
|
// return true if comment is undefined
|
||||||
if (!comment) return true;
|
if (!comment) return true;
|
||||||
return (
|
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