Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 31 commits into from
Sep 16, 2024
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
16be6ff
fix: svg not supported in image uploads
Palanikannan1437 Sep 9, 2024
f71ca58
fix: svg image file error message fixed
Palanikannan1437 Sep 9, 2024
7ebd714
feat: add custom image node for uploads
Palanikannan1437 Sep 11, 2024
5db937c
fix: combine two extensions
Palanikannan1437 Sep 11, 2024
3d81be7
fix: added new image extension to backend
Palanikannan1437 Sep 11, 2024
bd5482a
fix: type errors
Palanikannan1437 Sep 12, 2024
f080e3f
style: image drop node
aaryan610 Sep 12, 2024
8eaf6ed
style: image resize handler
aaryan610 Sep 12, 2024
d5a1d4e
fix: removed unused stuff
Palanikannan1437 Sep 12, 2024
56a0b9f
fix: types of updateAttributes
Palanikannan1437 Sep 12, 2024
d8308cf
fix: image insertion at pos and loading effect added
Palanikannan1437 Sep 12, 2024
4b61f9b
fix: resize image real time sync
Palanikannan1437 Sep 12, 2024
b200b33
fix: drag drop menu
Palanikannan1437 Sep 12, 2024
1e014d7
feat: custom image component editor
Palanikannan1437 Sep 13, 2024
47542b6
Merge branch 'feat/custom-image-component' into fix/image-upload-real…
Palanikannan1437 Sep 13, 2024
c0fb56c
fix: reverted back styles
Palanikannan1437 Sep 13, 2024
2502d51
fix: reverted back document info changes
Palanikannan1437 Sep 13, 2024
47cf78e
fix: css image css
Palanikannan1437 Sep 13, 2024
2583038
style: image selected and hover states
aaryan610 Sep 13, 2024
f8d85fd
refactor: custom image extension folder structure
aaryan610 Sep 13, 2024
5a95486
style: read-only image
aaryan610 Sep 13, 2024
2e1745e
chore: remove file handler
Palanikannan1437 Sep 13, 2024
fa5cfd5
fix: fixed multi time file opener
Palanikannan1437 Sep 13, 2024
78b1c50
fix: editor readonly content set properly
Palanikannan1437 Sep 13, 2024
8b9418d
fix: old images not rendered as new ones
Palanikannan1437 Sep 16, 2024
e297f7f
fix: drop upload fixed
Palanikannan1437 Sep 16, 2024
5c27a72
chore: remove console logs
Palanikannan1437 Sep 16, 2024
5da5770
Merge branch 'preview' into fix/image-upload-realtime
Palanikannan1437 Sep 16, 2024
da47e2b
fix: src of image node as dependency
Palanikannan1437 Sep 16, 2024
889dbb5
fix: helper library build fix
Palanikannan1437 Sep 16, 2024
1330a0f
fix: improved reflow/layout and fixed resizing
Palanikannan1437 Sep 16, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions packages/editor/src/ce/extensions/document-extensions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -21,7 +21,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
);

return extensions;
}, [dragDropEnabled, fileHandler.upload]);
}, [dragDropEnabled]);

return (
<EditorWrapper {...props} extensions={getExtensions()}>
Expand Down
3 changes: 2 additions & 1 deletion packages/editor/src/core/components/menus/block-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
12 changes: 6 additions & 6 deletions packages/editor/src/core/components/menus/menu-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import {
} from "lucide-react";
// helpers
import {
insertImageCommand,
insertTableCommand,
setText,
toggleBlockquote,
Expand All @@ -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;
Expand Down Expand Up @@ -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 [];
}
Expand All @@ -220,6 +220,6 @@ export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImag
NumberedListItem(editor),
QuoteItem(editor),
TableItem(editor),
ImageItem(editor, uploadFile),
ImageItem(editor),
];
}
2 changes: 2 additions & 0 deletions packages/editor/src/core/extensions/core-without-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -61,6 +62,7 @@ export const CoreEditorExtensionsWithoutProps = [
class: "rounded-md",
},
}),
CustomImageComponentWithoutProps(),
TiptapUnderline,
TextStyle,
TaskList.configure({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
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 imageRef = useRef<HTMLImageElement>(null);
const isResizing = useRef(false);
const aspectRatio = useRef(1);

useLayoutEffect(() => {
if (imageRef.current) {
const img = imageRef.current;
img.onload = () => {
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;
}, []);

useLayoutEffect(() => {
// for realtime resizing and undo/redo
setSize({ width, height });
}, [width, height]);

const handleResize = useCallback((e: MouseEvent | TouchEvent) => {
if (!isResizing.current || !containerRef.current) return;

const containerRect = containerRef.current.getBoundingClientRect();
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;

const newWidth = Math.max(clientX - containerRect.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>
);
};
Original file line number Diff line number Diff line change
@@ -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;
Palanikannan1437 marked this conversation as resolved.
Show resolved Hide resolved
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>
);
};
Loading
Loading