[WIKI-471] refactor: custom image extension (#7247)

* refactor: custom image extension

* refactor: extension config

* revert: image full screen component

* fix: undo operation
This commit is contained in:
Aaryan Khandelwal 2025-06-24 14:05:11 +05:30 committed by GitHub
parent 7045a1f2af
commit c1fa372c84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 375 additions and 514 deletions

View file

@ -2,7 +2,7 @@
import { CORE_EXTENSIONS } from "@/constants/extension"; import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions // extensions
import { type HeadingExtensionStorage } from "@/extensions"; import { type HeadingExtensionStorage } from "@/extensions";
import { type CustomImageExtensionStorage } from "@/extensions/custom-image"; import { type CustomImageExtensionStorage } from "@/extensions/custom-image/types";
import { type CustomLinkStorage } from "@/extensions/custom-link"; import { type CustomLinkStorage } from "@/extensions/custom-link";
import { type ImageExtensionStorage } from "@/extensions/image"; import { type ImageExtensionStorage } from "@/extensions/image";
import { type MentionExtensionStorage } from "@/extensions/mentions"; import { type MentionExtensionStorage } from "@/extensions/mentions";

View file

@ -12,10 +12,10 @@ import { CustomCalloutExtensionConfig } from "./callout/extension-config";
import { CustomCodeBlockExtensionWithoutProps } from "./code/without-props"; import { CustomCodeBlockExtensionWithoutProps } from "./code/without-props";
import { CustomCodeInlineExtension } from "./code-inline"; import { CustomCodeInlineExtension } from "./code-inline";
import { CustomColorExtension } from "./custom-color"; import { CustomColorExtension } from "./custom-color";
import { CustomImageExtensionConfig } from "./custom-image/extension-config";
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 { ImageExtensionConfig } from "./image";
import { CustomImageComponentWithoutProps } from "./image/image-component-without-props";
import { CustomMentionExtensionConfig } from "./mentions/extension-config"; import { CustomMentionExtensionConfig } from "./mentions/extension-config";
import { CustomQuoteExtension } from "./quote"; import { CustomQuoteExtension } from "./quote";
import { TableHeader, TableCell, TableRow, Table } from "./table"; import { TableHeader, TableCell, TableRow, Table } from "./table";
@ -72,12 +72,8 @@ export const CoreEditorExtensionsWithoutProps = [
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
}, },
}), }),
ImageExtensionWithoutProps.configure({ ImageExtensionConfig,
HTMLAttributes: { CustomImageExtensionConfig,
class: "rounded-md",
},
}),
CustomImageComponentWithoutProps,
TiptapUnderline, TiptapUnderline,
TextStyle, TextStyle,
TaskList.configure({ TaskList.configure({

View file

@ -1,68 +1,42 @@
import { NodeSelection } from "@tiptap/pm/state"; import { NodeSelection } from "@tiptap/pm/state";
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react"; import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
// plane utils // plane imports
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
// extensions // local imports
import { CustomBaseImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image"; import { Pixel, TCustomImageAttributes, TCustomImageSize } from "../types";
import { ensurePixelString } from "../utils";
import type { CustomImageNodeViewProps } from "./node-view";
import { ImageToolbarRoot } from "./toolbar";
import { ImageUploadStatus } from "./upload-status"; import { ImageUploadStatus } from "./upload-status";
const MIN_SIZE = 100; const MIN_SIZE = 100;
type Pixel = `${number}px`; type CustomImageBlockProps = CustomImageNodeViewProps & {
type PixelAttribute<TDefault> = Pixel | TDefault;
export type ImageAttributes = {
src: string | null;
width: PixelAttribute<"35%" | number>;
height: PixelAttribute<"auto" | number>;
aspectRatio: number | null;
id: string | null;
};
type Size = {
width: PixelAttribute<"35%">;
height: PixelAttribute<"auto">;
aspectRatio: number | null;
};
const ensurePixelString = <TDefault,>(value: Pixel | TDefault | number | undefined | null, defaultValue?: TDefault) => {
if (!value || value === defaultValue) {
return defaultValue;
}
if (typeof value === "number") {
return `${value}px` satisfies Pixel;
}
return value;
};
type CustomImageBlockProps = CustomBaseImageNodeViewProps & {
imageFromFileSystem: string | undefined;
setFailedToLoadImage: (isError: boolean) => void;
editorContainer: HTMLDivElement | null; editorContainer: HTMLDivElement | null;
imageFromFileSystem: string | undefined;
setEditorContainer: (editorContainer: HTMLDivElement | null) => void; setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
setFailedToLoadImage: (isError: boolean) => void;
src: string | undefined; src: string | undefined;
}; };
export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => { export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
// props // props
const { const {
node,
updateAttributes,
setFailedToLoadImage,
imageFromFileSystem,
selected,
getPos,
editor, editor,
editorContainer, editorContainer,
src: resolvedImageSrc, extension,
getPos,
imageFromFileSystem,
node,
selected,
setEditorContainer, setEditorContainer,
setFailedToLoadImage,
src: resolvedImageSrc,
updateAttributes,
} = props; } = props;
const { width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio, src: imgNodeSrc } = node.attrs; const { width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio, src: imgNodeSrc } = node.attrs;
// states // states
const [size, setSize] = useState<Size>({ const [size, setSize] = useState<TCustomImageSize>({
width: ensurePixelString(nodeWidth, "35%") ?? "35%", width: ensurePixelString(nodeWidth, "35%") ?? "35%",
height: ensurePixelString(nodeHeight, "auto") ?? "auto", height: ensurePixelString(nodeHeight, "auto") ?? "auto",
aspectRatio: nodeAspectRatio || null, aspectRatio: nodeAspectRatio || null,
@ -77,7 +51,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
const [hasTriedRestoringImageOnce, setHasTriedRestoringImageOnce] = useState(false); const [hasTriedRestoringImageOnce, setHasTriedRestoringImageOnce] = useState(false);
const updateAttributesSafely = useCallback( const updateAttributesSafely = useCallback(
(attributes: Partial<ImageAttributes>, errorMessage: string) => { (attributes: Partial<TCustomImageAttributes>, errorMessage: string) => {
try { try {
updateAttributes(attributes); updateAttributes(attributes);
} catch (error) { } catch (error) {
@ -114,7 +88,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE); const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
const initialHeight = initialWidth / aspectRatioCalculated; const initialHeight = initialWidth / aspectRatioCalculated;
const initialComputedSize = { const initialComputedSize: TCustomImageSize = {
width: `${Math.round(initialWidth)}px` satisfies Pixel, width: `${Math.round(initialWidth)}px` satisfies Pixel,
height: `${Math.round(initialHeight)}px` satisfies Pixel, height: `${Math.round(initialHeight)}px` satisfies Pixel,
aspectRatio: aspectRatioCalculated, aspectRatio: aspectRatioCalculated,
@ -139,7 +113,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
} }
} }
setInitialResizeComplete(true); setInitialResizeComplete(true);
}, [nodeWidth, updateAttributes, editorContainer, nodeAspectRatio]); }, [nodeWidth, updateAttributesSafely, editorContainer, nodeAspectRatio, setEditorContainer]);
// for real time resizing // for real time resizing
useLayoutEffect(() => { useLayoutEffect(() => {
@ -168,7 +142,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
const handleResizeEnd = useCallback(() => { const handleResizeEnd = useCallback(() => {
setIsResizing(false); setIsResizing(false);
updateAttributesSafely(size, "Failed to update attributes at the end of resizing:"); updateAttributesSafely(size, "Failed to update attributes at the end of resizing:");
}, [size, updateAttributes]); }, [size, updateAttributesSafely]);
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => { const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault(); e.preventDefault();
@ -242,7 +216,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
onLoad={handleImageLoad} onLoad={handleImageLoad}
onError={async (e) => { onError={async (e) => {
// for old image extension this command doesn't exist or if the image failed to load for the first time // for old image extension this command doesn't exist or if the image failed to load for the first time
if (!editor?.commands.restoreImage || hasTriedRestoringImageOnce) { if (!extension.options.restoreImage || hasTriedRestoringImageOnce) {
setFailedToLoadImage(true); setFailedToLoadImage(true);
return; return;
} }
@ -253,7 +227,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
if (!imgNodeSrc) { if (!imgNodeSrc) {
throw new Error("No source image to restore from"); throw new Error("No source image to restore from");
} }
await editor?.commands.restoreImage?.(imgNodeSrc); await extension.options.restoreImage?.(imgNodeSrc);
if (!imageRef.current) { if (!imageRef.current) {
throw new Error("Image reference not found"); throw new Error("Image reference not found");
} }
@ -289,10 +263,10 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
"absolute top-1 right-1 z-20 bg-black/40 rounded opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto transition-opacity" "absolute top-1 right-1 z-20 bg-black/40 rounded opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto transition-opacity"
} }
image={{ image={{
src: resolvedImageSrc,
aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio,
height: size.height,
width: size.width, width: size.width,
height: size.height,
aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio,
src: resolvedImageSrc,
}} }}
/> />
)} )}

View file

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

View file

@ -2,25 +2,26 @@ import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
// constants // constants
import { CORE_EXTENSIONS } from "@/constants/extension"; import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image";
// helpers // helpers
import { getExtensionStorage } from "@/helpers/get-extension-storage"; import { getExtensionStorage } from "@/helpers/get-extension-storage";
// local imports
import type { CustomImageExtension, TCustomImageAttributes } from "../types";
import { CustomImageBlock } from "./block";
import { CustomImageUploader } from "./uploader";
export type CustomBaseImageNodeViewProps = { export type CustomImageNodeViewProps = Omit<NodeViewProps, "extension" | "updateAttributes"> & {
extension: CustomImageExtension;
getPos: () => number; getPos: () => number;
editor: Editor; editor: Editor;
node: NodeViewProps["node"] & { node: NodeViewProps["node"] & {
attrs: ImageAttributes; attrs: TCustomImageAttributes;
}; };
updateAttributes: (attrs: Partial<ImageAttributes>) => void; updateAttributes: (attrs: Partial<TCustomImageAttributes>) => void;
selected: boolean; selected: boolean;
}; };
export type CustomImageNodeProps = NodeViewProps & CustomBaseImageNodeViewProps; export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) => {
const { editor, extension, node } = props;
export const CustomImageNode = (props: CustomImageNodeProps) => {
const { getPos, editor, node, updateAttributes, selected } = props;
const { src: imgNodeSrc } = node.attrs; const { src: imgNodeSrc } = node.attrs;
const [isUploaded, setIsUploaded] = useState(false); const [isUploaded, setIsUploaded] = useState(false);
@ -50,41 +51,37 @@ export const CustomImageNode = (props: CustomImageNodeProps) => {
}, [resolvedSrc]); }, [resolvedSrc]);
useEffect(() => { useEffect(() => {
if (!imgNodeSrc) {
setResolvedSrc(undefined);
return;
}
const getImageSource = async () => { const getImageSource = async () => {
// @ts-expect-error function not expected here, but will still work and don't remove await const url = await extension.options.getImageSource?.(imgNodeSrc);
const url: string = await editor?.commands?.getImageSource?.(imgNodeSrc); setResolvedSrc(url);
setResolvedSrc(url as string);
}; };
getImageSource(); getImageSource();
}, [imgNodeSrc]); }, [imgNodeSrc, extension.options]);
return ( return (
<NodeViewWrapper> <NodeViewWrapper>
<div className="p-0 mx-0 my-2" data-drag-handle ref={imageComponentRef}> <div className="p-0 mx-0 my-2" data-drag-handle ref={imageComponentRef}>
{(isUploaded || imageFromFileSystem) && !failedToLoadImage ? ( {(isUploaded || imageFromFileSystem) && !failedToLoadImage ? (
<CustomImageBlock <CustomImageBlock
imageFromFileSystem={imageFromFileSystem}
editorContainer={editorContainer} editorContainer={editorContainer}
editor={editor} imageFromFileSystem={imageFromFileSystem}
src={resolvedSrc}
getPos={getPos}
node={node}
setEditorContainer={setEditorContainer} setEditorContainer={setEditorContainer}
setFailedToLoadImage={setFailedToLoadImage} setFailedToLoadImage={setFailedToLoadImage}
selected={selected} src={resolvedSrc}
updateAttributes={updateAttributes} {...props}
/> />
) : ( ) : (
<CustomImageUploader <CustomImageUploader
editor={editor}
failedToLoadImage={failedToLoadImage} failedToLoadImage={failedToLoadImage}
getPos={getPos}
loadImageFromFileSystem={setImageFromFileSystem} loadImageFromFileSystem={setImageFromFileSystem}
maxFileSize={getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE).maxFileSize} maxFileSize={getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE).maxFileSize}
node={node}
setIsUploaded={setIsUploaded} setIsUploaded={setIsUploaded}
selected={selected} {...props}
updateAttributes={updateAttributes}
/> />
)} )}
</div> </div>

View file

@ -1,14 +1,14 @@
import { ExternalLink, Maximize, Minus, Plus, X } from "lucide-react"; import { ExternalLink, Maximize, Minus, Plus, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, useRef } from "react"; import { useCallback, useEffect, useMemo, useState, useRef } from "react";
// plane utils // plane imports
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
type Props = { type Props = {
image: { image: {
src: string;
height: string;
width: string; width: string;
height: string;
aspectRatio: number; aspectRatio: number;
src: string;
}; };
isOpen: boolean; isOpen: boolean;
toggleFullScreenMode: (val: boolean) => void; toggleFullScreenMode: (val: boolean) => void;

View file

@ -1,16 +1,16 @@
import { useState } from "react"; import { useState } from "react";
// plane utils // plane imports
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
// components // local imports
import { ImageFullScreenAction } from "./full-screen"; import { ImageFullScreenAction } from "./full-screen";
type Props = { type Props = {
containerClassName?: string; containerClassName?: string;
image: { image: {
src: string;
height: string;
width: string; width: string;
height: string;
aspectRatio: number; aspectRatio: number;
src: string;
}; };
}; };

View file

@ -1,28 +1,30 @@
import { ImageIcon } from "lucide-react"; import { ImageIcon } from "lucide-react";
import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react"; import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
// plane utils // plane imports
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
// constants // constants
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config"; import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
import { CORE_EXTENSIONS } from "@/constants/extension"; import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { CustomBaseImageNodeViewProps, getImageComponentImageFileMap } from "@/extensions/custom-image";
// helpers // helpers
import { EFileError } from "@/helpers/file"; import { EFileError } from "@/helpers/file";
import { getExtensionStorage } from "@/helpers/get-extension-storage"; import { getExtensionStorage } from "@/helpers/get-extension-storage";
// hooks // hooks
import { useUploader, useDropZone, uploadFirstFileAndInsertRemaining } from "@/hooks/use-file-upload"; import { useUploader, useDropZone, uploadFirstFileAndInsertRemaining } from "@/hooks/use-file-upload";
// local imports
import { getImageComponentImageFileMap } from "../utils";
import type { CustomImageNodeViewProps } from "./node-view";
type CustomImageUploaderProps = CustomBaseImageNodeViewProps & { type CustomImageUploaderProps = CustomImageNodeViewProps & {
maxFileSize: number;
loadImageFromFileSystem: (file: string) => void;
failedToLoadImage: boolean; failedToLoadImage: boolean;
loadImageFromFileSystem: (file: string) => void;
maxFileSize: number;
setIsUploaded: (isUploaded: boolean) => void; setIsUploaded: (isUploaded: boolean) => void;
}; };
export const CustomImageUploader = (props: CustomImageUploaderProps) => { export const CustomImageUploader = (props: CustomImageUploaderProps) => {
const { const {
editor, editor,
extension,
failedToLoadImage, failedToLoadImage,
getPos, getPos,
loadImageFromFileSystem, loadImageFromFileSystem,
@ -71,12 +73,13 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
} }
} }
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps
[imageComponentImageFileMap, imageEntityId, updateAttributes, getPos] [imageComponentImageFileMap, imageEntityId, updateAttributes, getPos]
); );
const uploadImageEditorCommand = useCallback( const uploadImageEditorCommand = useCallback(
async (file: File) => await editor?.commands.uploadImage(imageEntityId ?? "", file), async (file: File) => await extension.options.uploadImage?.(imageEntityId ?? "", file),
[editor, imageEntityId] [extension.options, imageEntityId]
); );
const handleProgressStatus = useCallback( const handleProgressStatus = useCallback(
@ -93,7 +96,6 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
// hooks // hooks
const { isUploading: isImageBeingUploaded, uploadFile } = useUploader({ const { isUploading: isImageBeingUploaded, uploadFile } = useUploader({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES, acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
// @ts-expect-error - TODO: fix typings, and don't remove await from here for now
editorCommand: uploadImageEditorCommand, editorCommand: uploadImageEditorCommand,
handleProgressStatus, handleProgressStatus,
loadFileFromFileSystem: loadImageFromFileSystem, loadFileFromFileSystem: loadImageFromFileSystem,
@ -128,7 +130,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
imageComponentImageFileMap?.set(imageEntityId ?? "", { ...meta, hasOpenedFileInputOnce: true }); imageComponentImageFileMap?.set(imageEntityId ?? "", { ...meta, hasOpenedFileInputOnce: true });
} }
} }
}, [meta, uploadFile, imageComponentImageFileMap]); }, [meta, uploadFile, imageComponentImageFileMap, imageEntityId]);
const onFileChange = useCallback( const onFileChange = useCallback(
async (e: ChangeEvent<HTMLInputElement>) => { async (e: ChangeEvent<HTMLInputElement>) => {
@ -163,7 +165,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
} }
return "Add an image"; return "Add an image";
}, [draggedInside, failedToLoadImage, isImageBeingUploaded]); }, [draggedInside, failedToLoadImage, isImageBeingUploaded, editor.isEditable]);
return ( return (
<div <div

View file

@ -1,180 +0,0 @@
import { Editor, mergeAttributes } from "@tiptap/core";
import { Image as BaseImageExtension } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { v4 as uuidv4 } from "uuid";
// constants
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { CustomImageNode } from "@/extensions/custom-image";
// helpers
import { isFileValid } from "@/helpers/file";
import { getExtensionStorage } from "@/helpers/get-extension-storage";
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// types
import { TFileHandler } from "@/types";
export type InsertImageComponentProps = {
file?: File;
pos?: number;
event: "insert" | "drop";
};
declare module "@tiptap/core" {
interface Commands<ReturnType> {
[CORE_EXTENSIONS.CUSTOM_IMAGE]: {
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
uploadImage: (blockId: string, file: File) => () => Promise<string> | undefined;
getImageSource?: (path: string) => () => Promise<string>;
restoreImage: (src: string) => () => Promise<void>;
};
}
}
export const getImageComponentImageFileMap = (editor: Editor) =>
getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE)?.fileMap;
export interface CustomImageExtensionStorage {
fileMap: Map<string, UploadEntity>;
deletedImageSet: Map<string, boolean>;
maxFileSize: number;
}
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };
export const CustomImageExtension = (props: TFileHandler) => {
const {
getAssetSrc,
upload,
restore: restoreImageFn,
validation: { maxFileSize },
} = props;
return BaseImageExtension.extend<Record<string, unknown>, CustomImageExtensionStorage>({
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
selectable: true,
group: "block",
atom: true,
draggable: true,
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
src: {
default: null,
},
height: {
default: "auto",
},
["id"]: {
default: null,
},
aspectRatio: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: "image-component",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["image-component", mergeAttributes(HTMLAttributes)];
},
addKeyboardShortcuts() {
return {
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
};
},
addStorage() {
return {
fileMap: new Map(),
deletedImageSet: new Map<string, boolean>(),
maxFileSize,
// escape markdown for images
markdown: {
serialize() {},
},
};
},
addCommands() {
return {
insertImageComponent:
(props) =>
({ commands }) => {
// Early return if there's an invalid file being dropped
if (
props?.file &&
!isFileValid({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
file: props.file,
maxFileSize,
onError: (_error, message) => alert(message),
})
) {
return false;
}
// generate a unique id for the image to keep track of dropped
// files' file data
const fileId = uuidv4();
const imageComponentImageFileMap = getImageComponentImageFileMap(this.editor);
if (imageComponentImageFileMap) {
if (props?.event === "drop" && props.file) {
imageComponentImageFileMap.set(fileId, {
file: props.file,
event: props.event,
});
} else if (props.event === "insert") {
imageComponentImageFileMap.set(fileId, {
event: props.event,
hasOpenedFileInputOnce: false,
});
}
}
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: (blockId, file) => async () => {
const fileUrl = await upload(blockId, file);
return fileUrl;
},
getImageSource: (path) => async () => await getAssetSrc(path),
restoreImage: (src) => async () => {
await restoreImageFn(src);
},
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});
};

View file

@ -0,0 +1,47 @@
import { mergeAttributes } from "@tiptap/core";
import { Image as BaseImageExtension } from "@tiptap/extension-image";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// local imports
import { type CustomImageExtension, ECustomImageAttributeNames, type InsertImageComponentProps } from "./types";
import { DEFAULT_CUSTOM_IMAGE_ATTRIBUTES } from "./utils";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
[CORE_EXTENSIONS.CUSTOM_IMAGE]: {
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
};
}
}
export const CustomImageExtensionConfig: CustomImageExtension = BaseImageExtension.extend({
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
group: "block",
atom: true,
addAttributes() {
const attributes = {
...this.parent?.(),
...Object.values(ECustomImageAttributeNames).reduce((acc, value) => {
acc[value] = {
default: DEFAULT_CUSTOM_IMAGE_ATTRIBUTES[value],
};
return acc;
}, {}),
};
return attributes;
},
parseHTML() {
return [
{
tag: "image-component",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["image-component", mergeAttributes(HTMLAttributes)];
},
});

View file

@ -0,0 +1,121 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
import { v4 as uuidv4 } from "uuid";
// constants
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
// helpers
import { isFileValid } from "@/helpers/file";
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// types
import type { TFileHandler, TReadOnlyFileHandler } from "@/types";
// local imports
import { CustomImageNodeView } from "./components/node-view";
import { CustomImageExtensionConfig } from "./extension-config";
import { getImageComponentImageFileMap } from "./utils";
type Props = {
fileHandler: TFileHandler | TReadOnlyFileHandler;
isEditable: boolean;
};
export const CustomImageExtension = (props: Props) => {
const { fileHandler, isEditable } = props;
// derived values
const { getAssetSrc, restore: restoreImageFn } = fileHandler;
return CustomImageExtensionConfig.extend({
selectable: isEditable,
draggable: isEditable,
addOptions() {
const upload = "upload" in fileHandler ? fileHandler.upload : undefined;
return {
...this.parent?.(),
getImageSource: getAssetSrc,
restoreImage: restoreImageFn,
uploadImage: upload,
};
},
addStorage() {
const maxFileSize = "validation" in fileHandler ? fileHandler.validation?.maxFileSize : 0;
return {
fileMap: new Map(),
deletedImageSet: new Map<string, boolean>(),
maxFileSize,
// escape markdown for images
markdown: {
serialize() {},
},
};
},
addCommands() {
return {
insertImageComponent:
(props) =>
({ commands }) => {
// Early return if there's an invalid file being dropped
if (
props?.file &&
!isFileValid({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
file: props.file,
maxFileSize: this.storage.maxFileSize,
onError: (_error, message) => alert(message),
})
) {
return false;
}
// generate a unique id for the image to keep track of dropped
// files' file data
const fileId = uuidv4();
const imageComponentImageFileMap = getImageComponentImageFileMap(this.editor);
if (imageComponentImageFileMap) {
if (props?.event === "drop" && props.file) {
imageComponentImageFileMap.set(fileId, {
file: props.file,
event: props.event,
});
} else if (props.event === "insert") {
imageComponentImageFileMap.set(fileId, {
event: props.event,
hasOpenedFileInputOnce: false,
});
}
}
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,
});
},
};
},
addKeyboardShortcuts() {
return {
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNodeView);
},
});
};

View file

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

View file

@ -1,79 +0,0 @@
import { mergeAttributes } from "@tiptap/core";
import { Image as BaseImageExtension } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// components
import { CustomImageNode, CustomImageExtensionStorage } from "@/extensions/custom-image";
// types
import { TReadOnlyFileHandler } from "@/types";
export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
const { getAssetSrc, restore: restoreImageFn } = props;
return BaseImageExtension.extend<Record<string, unknown>, CustomImageExtensionStorage>({
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
selectable: false,
group: "block",
atom: true,
draggable: false,
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
src: {
default: null,
},
height: {
default: "auto",
},
["id"]: {
default: null,
},
aspectRatio: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: "image-component",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["image-component", mergeAttributes(HTMLAttributes)];
},
addStorage() {
return {
fileMap: new Map(),
deletedImageSet: new Map<string, boolean>(),
maxFileSize: 0,
// escape markdown for images
markdown: {
serialize() {},
},
};
},
addCommands() {
return {
getImageSource: (path: string) => async () => await getAssetSrc(path),
restoreImage: (src) => async () => {
await restoreImageFn(src);
},
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});
};

View file

@ -0,0 +1,51 @@
import type { Node } from "@tiptap/core";
// types
import type { TFileHandler } from "@/types";
export enum ECustomImageAttributeNames {
ID = "id",
WIDTH = "width",
HEIGHT = "height",
ASPECT_RATIO = "aspectRatio",
SOURCE = "src",
}
export type Pixel = `${number}px`;
export type PixelAttribute<TDefault> = Pixel | TDefault;
export type TCustomImageSize = {
width: PixelAttribute<"35%">;
height: PixelAttribute<"auto">;
aspectRatio: number | null;
};
export type TCustomImageAttributes = {
[ECustomImageAttributeNames.ID]: string | null;
[ECustomImageAttributeNames.WIDTH]: PixelAttribute<"35%" | number> | null;
[ECustomImageAttributeNames.HEIGHT]: PixelAttribute<"auto" | number> | null;
[ECustomImageAttributeNames.ASPECT_RATIO]: number | null;
[ECustomImageAttributeNames.SOURCE]: string | null;
};
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };
export type InsertImageComponentProps = {
file?: File;
pos?: number;
event: "insert" | "drop";
};
export type CustomImageExtensionOptions = {
getImageSource: TFileHandler["getAssetSrc"];
restoreImage: TFileHandler["restore"];
uploadImage?: TFileHandler["upload"];
};
export type CustomImageExtensionStorage = {
fileMap: Map<string, UploadEntity>;
deletedImageSet: Map<string, boolean>;
maxFileSize: number;
};
export type CustomImageExtension = Node<CustomImageExtensionOptions, CustomImageExtensionStorage>;

View file

@ -0,0 +1,33 @@
import type { Editor } from "@tiptap/core";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import { getExtensionStorage } from "@/helpers/get-extension-storage";
// local imports
import { ECustomImageAttributeNames, type Pixel, type TCustomImageAttributes } from "./types";
export const DEFAULT_CUSTOM_IMAGE_ATTRIBUTES: TCustomImageAttributes = {
[ECustomImageAttributeNames.SOURCE]: null,
[ECustomImageAttributeNames.ID]: null,
[ECustomImageAttributeNames.WIDTH]: "35%",
[ECustomImageAttributeNames.HEIGHT]: "auto",
[ECustomImageAttributeNames.ASPECT_RATIO]: null,
};
export const getImageComponentImageFileMap = (editor: Editor) =>
getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE)?.fileMap;
export const ensurePixelString = <TDefault>(
value: Pixel | TDefault | number | undefined | null,
defaultValue?: TDefault
) => {
if (!value || value === defaultValue) {
return defaultValue;
}
if (typeof value === "number") {
return `${value}px` satisfies Pixel;
}
return value;
};

View file

@ -16,7 +16,6 @@ import {
CustomCodeInlineExtension, CustomCodeInlineExtension,
CustomColorExtension, CustomColorExtension,
CustomHorizontalRule, CustomHorizontalRule,
CustomImageExtension,
CustomKeymap, CustomKeymap,
CustomLinkExtension, CustomLinkExtension,
CustomMentionExtension, CustomMentionExtension,
@ -38,6 +37,8 @@ import { getExtensionStorage } from "@/helpers/get-extension-storage";
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions"; import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
// types // types
import type { IEditorProps } from "@/types"; import type { IEditorProps } from "@/types";
// local imports
import { CustomImageExtension } from "./custom-image/extension";
type TArguments = Pick< type TArguments = Pick<
IEditorProps, IEditorProps,
@ -191,12 +192,13 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
if (!disabledExtensions.includes("image")) { if (!disabledExtensions.includes("image")) {
extensions.push( extensions.push(
ImageExtension(fileHandler).configure({ ImageExtension({
HTMLAttributes: { fileHandler,
class: "rounded-md",
},
}), }),
CustomImageExtension(fileHandler) CustomImageExtension({
fileHandler,
isEditable: editable,
})
); );
} }

View file

@ -1,6 +1,12 @@
import { Image as BaseImageExtension } from "@tiptap/extension-image"; import { Image as BaseImageExtension } from "@tiptap/extension-image";
// local imports
import { CustomImageExtensionOptions } from "../custom-image/types";
import { ImageExtensionStorage } from "./extension";
export const ImageExtensionWithoutProps = BaseImageExtension.extend({ export const ImageExtensionConfig = BaseImageExtension.extend<
Pick<CustomImageExtensionOptions, "getImageSource">,
ImageExtensionStorage
>({
addAttributes() { addAttributes() {
return { return {
...this.parent?.(), ...this.parent?.(),

View file

@ -1,23 +1,33 @@
import { Image as BaseImageExtension } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react"; import { ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomImageNode } from "@/extensions";
// helpers // helpers
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary"; import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// types // types
import { TFileHandler } from "@/types"; import type { TFileHandler, TReadOnlyFileHandler } from "@/types";
// local imports
import { CustomImageNodeView } from "../custom-image/components/node-view";
import { ImageExtensionConfig } from "./extension-config";
export type ImageExtensionStorage = { export type ImageExtensionStorage = {
deletedImageSet: Map<string, boolean>; deletedImageSet: Map<string, boolean>;
}; };
export const ImageExtension = (fileHandler: TFileHandler) => { type Props = {
const { fileHandler: TFileHandler | TReadOnlyFileHandler;
getAssetSrc, };
validation: { maxFileSize },
} = fileHandler; export const ImageExtension = (props: Props) => {
const { fileHandler } = props;
// derived values
const { getAssetSrc } = fileHandler;
return ImageExtensionConfig.extend({
addOptions() {
return {
...this.parent?.(),
getImageSource: getAssetSrc,
};
},
return BaseImageExtension.extend<unknown, ImageExtensionStorage>({
addKeyboardShortcuts() { addKeyboardShortcuts() {
return { return {
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name), ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
@ -27,36 +37,17 @@ export const ImageExtension = (fileHandler: TFileHandler) => {
// storage to keep track of image states Map<src, isDeleted> // storage to keep track of image states Map<src, isDeleted>
addStorage() { addStorage() {
const maxFileSize = "validation" in fileHandler ? fileHandler.validation?.maxFileSize : 0;
return { return {
deletedImageSet: new Map<string, boolean>(), deletedImageSet: new Map<string, boolean>(),
maxFileSize, maxFileSize,
}; };
}, },
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
height: {
default: null,
},
aspectRatio: {
default: null,
},
};
},
addCommands() {
return {
getImageSource: (path: string) => async () => await getAssetSrc(path),
};
},
// render custom image node // render custom image node
addNodeView() { addNodeView() {
return ReactNodeViewRenderer(CustomImageNode); return ReactNodeViewRenderer(CustomImageNodeView);
}, },
}); });
}; };

View file

@ -1,56 +0,0 @@
import { mergeAttributes } from "@tiptap/core";
import { Image as BaseImageExtension } from "@tiptap/extension-image";
// local imports
import { ImageExtensionStorage } from "./extension";
export const CustomImageComponentWithoutProps = BaseImageExtension.extend<
Record<string, unknown>,
ImageExtensionStorage
>({
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,
},
aspectRatio: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: "image-component",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["image-component", mergeAttributes(HTMLAttributes)];
},
addStorage() {
return {
fileMap: new Map(),
deletedImageSet: new Map<string, boolean>(),
maxFileSize: 0,
};
},
});

View file

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

View file

@ -1,37 +0,0 @@
import { Image as BaseImageExtension } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomImageNode } from "@/extensions";
// types
import { TReadOnlyFileHandler } from "@/types";
export const ReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
const { getAssetSrc } = props;
return BaseImageExtension.extend({
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
height: {
default: null,
},
aspectRatio: {
default: null,
},
};
},
addCommands() {
return {
getImageSource: (path: string) => async () => await getAssetSrc(path),
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});
};

View file

@ -1,7 +1,6 @@
export * from "./callout"; export * from "./callout";
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";

View file

@ -12,7 +12,6 @@ import {
CustomHorizontalRule, CustomHorizontalRule,
CustomLinkExtension, CustomLinkExtension,
CustomTypographyExtension, CustomTypographyExtension,
ReadOnlyImageExtension,
CustomCodeBlockExtension, CustomCodeBlockExtension,
CustomCodeInlineExtension, CustomCodeInlineExtension,
TableHeader, TableHeader,
@ -20,11 +19,11 @@ import {
TableRow, TableRow,
Table, Table,
CustomMentionExtension, CustomMentionExtension,
CustomReadOnlyImageExtension,
CustomTextAlignExtension, CustomTextAlignExtension,
CustomCalloutReadOnlyExtension, CustomCalloutReadOnlyExtension,
CustomColorExtension, CustomColorExtension,
UtilityExtension, UtilityExtension,
ImageExtension,
} from "@/extensions"; } from "@/extensions";
// helpers // helpers
import { isValidHttpUrl } from "@/helpers/common"; import { isValidHttpUrl } from "@/helpers/common";
@ -32,6 +31,8 @@ import { isValidHttpUrl } from "@/helpers/common";
import { CoreReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions"; import { CoreReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions";
// types // types
import type { IReadOnlyEditorProps } from "@/types"; import type { IReadOnlyEditorProps } from "@/types";
// local imports
import { CustomImageExtension } from "./custom-image/extension";
type Props = Pick<IReadOnlyEditorProps, "disabledExtensions" | "flaggedExtensions" | "fileHandler" | "mentionHandler">; type Props = Pick<IReadOnlyEditorProps, "disabledExtensions" | "flaggedExtensions" | "fileHandler" | "mentionHandler">;
@ -135,12 +136,13 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
if (!disabledExtensions.includes("image")) { if (!disabledExtensions.includes("image")) {
extensions.push( extensions.push(
ReadOnlyImageExtension(fileHandler).configure({ ImageExtension({
HTMLAttributes: { fileHandler,
class: "rounded-md",
},
}), }),
CustomReadOnlyImageExtension(fileHandler) CustomImageExtension({
fileHandler,
isEditable: false,
})
); );
} }

View file

@ -2,8 +2,8 @@ import { Editor, Range } from "@tiptap/core";
// constants // constants
import { CORE_EXTENSIONS } from "@/constants/extension"; import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions // extensions
import { InsertImageComponentProps } from "@/extensions";
import { replaceCodeWithText } from "@/extensions/code/utils/replace-code-block-with-text"; import { replaceCodeWithText } from "@/extensions/code/utils/replace-code-block-with-text";
import type { InsertImageComponentProps } from "@/extensions/custom-image/types";
// helpers // helpers
import { findTableAncestor } from "@/helpers/common"; import { findTableAncestor } from "@/helpers/common";