[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:
parent
7045a1f2af
commit
c1fa372c84
24 changed files with 375 additions and 514 deletions
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
export * from "./toolbar";
|
|
||||||
export * from "./image-block";
|
|
||||||
export * from "./image-node";
|
|
||||||
export * from "./image-uploader";
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -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)];
|
||||||
|
},
|
||||||
|
});
|
||||||
121
packages/editor/src/core/extensions/custom-image/extension.ts
Normal file
121
packages/editor/src/core/extensions/custom-image/extension.ts
Normal 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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export * from "./components";
|
|
||||||
export * from "./custom-image";
|
|
||||||
export * from "./read-only-custom-image";
|
|
||||||
|
|
@ -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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
51
packages/editor/src/core/extensions/custom-image/types.ts
Normal file
51
packages/editor/src/core/extensions/custom-image/types.ts
Normal 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>;
|
||||||
33
packages/editor/src/core/extensions/custom-image/utils.ts
Normal file
33
packages/editor/src/core/extensions/custom-image/utils.ts
Normal 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;
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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?.(),
|
||||||
|
|
@ -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);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -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";
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue