diff --git a/packages/editor/src/core/components/editors/editor-content.tsx b/packages/editor/src/core/components/editors/editor-content.tsx index 691bc1002b..b05457f2e6 100644 --- a/packages/editor/src/core/components/editors/editor-content.tsx +++ b/packages/editor/src/core/components/editors/editor-content.tsx @@ -1,7 +1,5 @@ import { FC, ReactNode } from "react"; import { Editor, EditorContent } from "@tiptap/react"; -// extensions -import { ImageResizer } from "@/extensions/image"; interface EditorContentProps { children?: ReactNode; @@ -16,7 +14,6 @@ export const EditorContentWrapper: FC = (props) => { return (
editor?.chain().focus(undefined, { scrollIntoView: false }).run()}> - {editor?.isActive("image") && editor?.isEditable && } {children}
); diff --git a/packages/editor/src/core/components/menus/block-menu.tsx b/packages/editor/src/core/components/menus/block-menu.tsx index a90e39ca22..e1715b5f78 100644 --- a/packages/editor/src/core/components/menus/block-menu.tsx +++ b/packages/editor/src/core/components/menus/block-menu.tsx @@ -101,7 +101,8 @@ export const BlockMenu = (props: BlockMenuProps) => { icon: Copy, key: "duplicate", label: "Duplicate", - isDisabled: editor.state.selection.content().content.firstChild?.type.name === "image", + isDisabled: + editor.state.selection.content().content.firstChild?.type.name === "image" || editor.isActive("imageComponent"), onClick: (e) => { e.preventDefault(); e.stopPropagation(); diff --git a/packages/editor/src/core/components/menus/menu-items.ts b/packages/editor/src/core/components/menus/menu-items.ts index 63d76117ab..b60196beac 100644 --- a/packages/editor/src/core/components/menus/menu-items.ts +++ b/packages/editor/src/core/components/menus/menu-items.ts @@ -23,7 +23,6 @@ import { } from "lucide-react"; // helpers import { - insertImageCommand, insertTableCommand, setText, toggleBlockquote, @@ -43,7 +42,7 @@ import { toggleUnderline, } from "@/helpers/editor-commands"; // types -import { TEditorCommands, UploadImage } from "@/types"; +import { TEditorCommands } from "@/types"; export interface EditorMenuItem { key: TEditorCommands; diff --git a/packages/editor/src/core/extensions/custom-image/custom-image.ts b/packages/editor/src/core/extensions/custom-image/custom-image.ts index ee942c1bd7..d84f443aa7 100644 --- a/packages/editor/src/core/extensions/custom-image/custom-image.ts +++ b/packages/editor/src/core/extensions/custom-image/custom-image.ts @@ -5,9 +5,11 @@ import { v4 as uuidv4 } from "uuid"; // extensions import { CustomImageNode } from "@/extensions/custom-image"; // plugins -import { isFileValid } from "@/plugins/image"; +import { TrackImageDeletionPlugin, TrackImageRestorationPlugin, isFileValid } from "@/plugins/image"; // types import { TFileHandler } from "@/types"; +// helpers +import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary"; declare module "@tiptap/core" { interface Commands { @@ -22,10 +24,10 @@ export interface UploadImageExtensionStorage { fileMap: Map; } -export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce: boolean }; +export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean }; export const CustomImageExtension = (props: TFileHandler) => { - const { upload } = props; + const { upload, delete: deleteImage, restore: restoreImage } = props; return Image.extend<{}, UploadImageExtensionStorage>({ name: "imageComponent", @@ -57,9 +59,6 @@ export const CustomImageExtension = (props: TFileHandler) => { { tag: "image-component", }, - { - tag: "img", - }, ]; }, @@ -67,9 +66,42 @@ export const CustomImageExtension = (props: TFileHandler) => { return ["image-component", mergeAttributes(HTMLAttributes)]; }, + onCreate(this) { + const imageSources = new Set(); + this.editor.state.doc.descendants((node) => { + if (node.type.name === this.name) { + imageSources.add(node.attrs.src); + } + }); + imageSources.forEach(async (src) => { + try { + const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); + console.log("assetUrlWithWorkspaceId restore ", this.name, assetUrlWithWorkspaceId); + await restoreImage(assetUrlWithWorkspaceId); + } catch (error) { + console.error("Error restoring image: ", error); + } + }); + }, + + addKeyboardShortcuts() { + return { + ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name), + ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name), + }; + }, + + addProseMirrorPlugins() { + return [ + TrackImageDeletionPlugin(this.editor, deleteImage, this.name), + TrackImageRestorationPlugin(this.editor, restoreImage, this.name), + ]; + }, + addStorage() { return { fileMap: new Map(), + deletedImageSet: new Map(), }; }, diff --git a/packages/editor/src/core/extensions/image/extension.tsx b/packages/editor/src/core/extensions/image/extension.tsx index 98961b7f0f..2ed19f3265 100644 --- a/packages/editor/src/core/extensions/image/extension.tsx +++ b/packages/editor/src/core/extensions/image/extension.tsx @@ -1,43 +1,40 @@ import ImageExt from "@tiptap/extension-image"; +import { ReactNodeViewRenderer } from "@tiptap/react"; // helpers import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary"; // plugins -import { - IMAGE_NODE_TYPE, - ImageExtensionStorage, - TrackImageDeletionPlugin, - TrackImageRestorationPlugin, - UploadImagesPlugin, -} from "@/plugins/image"; +import { ImageExtensionStorage, TrackImageDeletionPlugin, TrackImageRestorationPlugin } from "@/plugins/image"; // types import { DeleteImage, RestoreImage } from "@/types"; +// extensions +import { CustomImageNode } from "@/extensions"; export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreImage, cancelUploadImage?: () => void) => ImageExt.extend({ addKeyboardShortcuts() { return { - ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", "image"), - ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", "image"), + ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name), + ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name), }; }, addProseMirrorPlugins() { return [ - UploadImagesPlugin(this.editor, cancelUploadImage), - TrackImageDeletionPlugin(this.editor, deleteImage), - TrackImageRestorationPlugin(this.editor, restoreImage), + TrackImageDeletionPlugin(this.editor, deleteImage, this.name), + TrackImageRestorationPlugin(this.editor, restoreImage, this.name), ]; }, onCreate(this) { const imageSources = new Set(); this.editor.state.doc.descendants((node) => { - if (node.type.name === IMAGE_NODE_TYPE) { + if (node.type.name === this.name) { imageSources.add(node.attrs.src); } }); imageSources.forEach(async (src) => { try { const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); + console.log("assetUrlWithWorkspaceId restore ", this.name, assetUrlWithWorkspaceId); await restoreImage(assetUrlWithWorkspaceId); } catch (error) { console.error("Error restoring image: ", error); @@ -64,4 +61,9 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm }, }; }, + + // render custom image node + addNodeView() { + return ReactNodeViewRenderer(CustomImageNode); + }, }); diff --git a/packages/editor/src/core/extensions/image/image-component-without-props.tsx b/packages/editor/src/core/extensions/image/image-component-without-props.tsx index de27ba98ee..770a336e71 100644 --- a/packages/editor/src/core/extensions/image/image-component-without-props.tsx +++ b/packages/editor/src/core/extensions/image/image-component-without-props.tsx @@ -1,6 +1,8 @@ import { mergeAttributes } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; import { Image } from "@tiptap/extension-image"; -import { UploadImageExtensionStorage } from "@/extensions/custom-image"; +// extensions +import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions"; export const CustomImageComponentWithoutProps = () => Image.extend<{}, UploadImageExtensionStorage>({ @@ -33,9 +35,6 @@ export const CustomImageComponentWithoutProps = () => { tag: "image-component", }, - { - tag: "img", - }, ]; }, @@ -46,8 +45,13 @@ export const CustomImageComponentWithoutProps = () => addStorage() { return { fileMap: new Map(), + deletedImageSet: new Map(), }; }, + + addNodeView() { + return ReactNodeViewRenderer(CustomImageNode); + }, }); export default CustomImageComponentWithoutProps; diff --git a/packages/editor/src/core/extensions/image/image-extension-without-props.tsx b/packages/editor/src/core/extensions/image/image-extension-without-props.tsx index 0d505000c7..bd9ca3c820 100644 --- a/packages/editor/src/core/extensions/image/image-extension-without-props.tsx +++ b/packages/editor/src/core/extensions/image/image-extension-without-props.tsx @@ -1,4 +1,7 @@ import ImageExt from "@tiptap/extension-image"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +// extensions +import { CustomImageNode } from "@/extensions"; export const ImageExtensionWithoutProps = () => ImageExt.extend({ @@ -13,4 +16,8 @@ export const ImageExtensionWithoutProps = () => }, }; }, + + addNodeView() { + return ReactNodeViewRenderer(CustomImageNode); + }, }); diff --git a/packages/editor/src/core/extensions/image/image-resize.tsx b/packages/editor/src/core/extensions/image/image-resize.tsx deleted file mode 100644 index 334c402bc9..0000000000 --- a/packages/editor/src/core/extensions/image/image-resize.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { useState } from "react"; -import { Editor } from "@tiptap/react"; -import Moveable from "react-moveable"; - -type Props = { - editor: Editor; - id: string; -}; - -const getImageElement = (editorId: string): HTMLImageElement | null => - document.querySelector(`#editor-container-${editorId} .ProseMirror-selectednode`); - -export const ImageResizer = (props: Props) => { - const { editor, id } = props; - // states - const [aspectRatio, setAspectRatio] = useState(1); - - const updateMediaSize = () => { - const imageElement = getImageElement(id); - - if (!imageElement) return; - - const selection = editor.state.selection; - - // Use the style width/height if available, otherwise fall back to the element's natural width/height - const width = imageElement.style.width - ? Number(imageElement.style.width.replace("px", "")) - : imageElement.getAttribute("width"); - const height = imageElement.style.height - ? Number(imageElement.style.height.replace("px", "")) - : imageElement.getAttribute("height"); - - editor.commands.setImage({ - src: imageElement.src, - width: width, - height: height, - } as any); - editor.commands.setNodeSelection(selection.from); - }; - - return ( - { - const imageElement = getImageElement(id); - if (imageElement) { - const originalWidth = Number(imageElement.width); - const originalHeight = Number(imageElement.height); - setAspectRatio(originalWidth / originalHeight); - } - }} - onResize={({ target, width, height, delta }) => { - if (delta[0] || delta[1]) { - let newWidth, newHeight; - if (delta[0]) { - // Width change detected - newWidth = Math.max(width, 100); - newHeight = newWidth / aspectRatio; - } else if (delta[1]) { - // Height change detected - newHeight = Math.max(height, 100); - newWidth = newHeight * aspectRatio; - } - target.style.width = `${newWidth}px`; - target.style.height = `${newHeight}px`; - } - }} - onResizeEnd={() => { - updateMediaSize(); - }} - scalable - renderDirections={["se"]} - onScale={({ target, transform }) => { - target.style.transform = transform; - }} - /> - ); -}; diff --git a/packages/editor/src/core/extensions/image/index.ts b/packages/editor/src/core/extensions/image/index.ts index 3e2f7518dd..9c7dc65d78 100644 --- a/packages/editor/src/core/extensions/image/index.ts +++ b/packages/editor/src/core/extensions/image/index.ts @@ -1,4 +1,3 @@ export * from "./extension"; export * from "./image-extension-without-props"; -export * from "./image-resize"; export * from "./read-only-image"; diff --git a/packages/editor/src/core/extensions/image/read-only-image.tsx b/packages/editor/src/core/extensions/image/read-only-image.tsx index 8112eba4ec..1605174b32 100644 --- a/packages/editor/src/core/extensions/image/read-only-image.tsx +++ b/packages/editor/src/core/extensions/image/read-only-image.tsx @@ -1,4 +1,7 @@ import Image from "@tiptap/extension-image"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +// extensions +import { CustomImageNode } from "@/extensions"; export const ReadOnlyImageExtension = Image.extend({ addAttributes() { @@ -12,4 +15,7 @@ export const ReadOnlyImageExtension = Image.extend({ }, }; }, + addNodeView() { + return ReactNodeViewRenderer(CustomImageNode); + }, }); diff --git a/packages/editor/src/core/helpers/editor-commands.ts b/packages/editor/src/core/helpers/editor-commands.ts index eebb69f0a6..7cf3e8d1f1 100644 --- a/packages/editor/src/core/helpers/editor-commands.ts +++ b/packages/editor/src/core/helpers/editor-commands.ts @@ -156,23 +156,3 @@ export const insertImageCommand = ( }; input.click(); }; - -export const insertImageNewCommand = (editor: Editor, saveSelection?: Selection | null, range?: Range) => { - // if (range) editor.chain().focus().deleteRange(range).setImageUpload(saveSelection).run(); - // const input = document.createElement("input"); - // input.type = "file"; - // input.accept = ".jpeg, .jpg, .png, .webp"; - // input.onchange = async () => { - // if (input.files?.length) { - // const file = input.files[0]; - // const pos = saveSelection?.anchor ?? editor.view.state.selection.from; - // const url = await (editor?.commands.uploadImage(file) as unknown as Promise); - // if (!url) { - // throw new Error("Something went wrong while uploading the image"); - // } - // editor.chain().setImageBlock({ src: url }).focus().run(); - // // startImageUpload(editor, file, editor.view, pos, uploadFile); - // } - // }; - // input.click(); -}; diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index 15b5031849..0542418e5d 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -67,11 +67,6 @@ export const useEditor = (props: CustomEditorProps) => { }), ...editorProps, }, - immediatelyRender: true, - shouldRerenderOnTransaction: false, - onContentError: (error) => { - console.error("Error rendering content:", error); - }, extensions: [ ...CoreEditorExtensions({ enableHistory, diff --git a/packages/editor/src/core/plugins/image/delete-image.ts b/packages/editor/src/core/plugins/image/delete-image.ts index 8dc1bf0722..769b6f2458 100644 --- a/packages/editor/src/core/plugins/image/delete-image.ts +++ b/packages/editor/src/core/plugins/image/delete-image.ts @@ -1,17 +1,17 @@ import { Editor } from "@tiptap/core"; -import { EditorState, Plugin, Transaction } from "@tiptap/pm/state"; +import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; // plugins -import { IMAGE_NODE_TYPE, deleteKey, type ImageNode } from "@/plugins/image"; +import { type ImageNode } from "@/plugins/image"; // types import { DeleteImage } from "@/types"; -export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImage): Plugin => +export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImage, nodeType: string): Plugin => new Plugin({ - key: deleteKey, + key: new PluginKey(`delete-${nodeType}`), appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { const newImageSources = new Set(); newState.doc.descendants((node) => { - if (node.type.name === IMAGE_NODE_TYPE) { + if (node.type.name === nodeType) { newImageSources.add(node.attrs.src); } }); @@ -25,7 +25,7 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag // iterate through all the nodes in the old state oldState.doc.descendants((oldNode) => { // if the node is not an image, then return as no point in checking - if (oldNode.type.name !== IMAGE_NODE_TYPE) return; + if (oldNode.type.name !== nodeType) return; // Check if the node has been deleted or replaced if (!newImageSources.has(oldNode.attrs.src)) { @@ -35,7 +35,7 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag removedImages.forEach(async (node) => { const src = node.attrs.src; - editor.storage.image.deletedImageSet.set(src, true); + editor.storage[nodeType].deletedImageSet.set(src, true); await onNodeDeleted(src, deleteImage); }); }); diff --git a/packages/editor/src/core/plugins/image/restore-image.ts b/packages/editor/src/core/plugins/image/restore-image.ts index 036df9b887..cbcd335322 100644 --- a/packages/editor/src/core/plugins/image/restore-image.ts +++ b/packages/editor/src/core/plugins/image/restore-image.ts @@ -1,17 +1,17 @@ import { Editor } from "@tiptap/core"; -import { EditorState, Plugin, Transaction } from "@tiptap/pm/state"; +import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; // plugins -import { IMAGE_NODE_TYPE, ImageNode, restoreKey } from "@/plugins/image"; +import { ImageNode } from "@/plugins/image"; // types import { RestoreImage } from "@/types"; -export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: RestoreImage): Plugin => +export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: RestoreImage, nodeType: string): Plugin => new Plugin({ - key: restoreKey, + key: new PluginKey(`restore-${nodeType}`), appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { const oldImageSources = new Set(); oldState.doc.descendants((node) => { - if (node.type.name === IMAGE_NODE_TYPE) { + if (node.type.name === nodeType) { oldImageSources.add(node.attrs.src); } }); @@ -22,20 +22,21 @@ export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: Restor const addedImages: ImageNode[] = []; newState.doc.descendants((node, pos) => { - if (node.type.name !== IMAGE_NODE_TYPE) return; + if (node.type.name !== nodeType) return; if (pos < 0 || pos > newState.doc.content.size) return; if (oldImageSources.has(node.attrs.src)) return; addedImages.push(node as ImageNode); }); addedImages.forEach(async (image) => { - const wasDeleted = editor.storage.image.deletedImageSet.get(image.attrs.src); + const src = image.attrs.src; + const wasDeleted = editor.storage[nodeType].deletedImageSet.get(src); if (wasDeleted === undefined) { - editor.storage.image.deletedImageSet.set(image.attrs.src, false); + editor.storage[nodeType].deletedImageSet.set(src, false); } else if (wasDeleted === true) { try { - await onNodeRestored(image.attrs.src, restoreImage); - editor.storage.image.deletedImageSet.set(image.attrs.src, false); + await onNodeRestored(src, restoreImage); + editor.storage[nodeType].deletedImageSet.set(src, false); } catch (error) { console.error("Error restoring image: ", error); } diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 15ec791375..fc9fe1ac60 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -23,7 +23,6 @@ export * from "@/helpers/common"; export * from "@/helpers/editor-commands"; export * from "@/helpers/yjs"; export * from "@/extensions/table/table"; -export { startImageUpload } from "@/plugins/image"; // components export * from "@/components/menus"; diff --git a/packages/editor/src/styles/drag-drop.css b/packages/editor/src/styles/drag-drop.css index 72d190f32f..9fb8a2c36a 100644 --- a/packages/editor/src/styles/drag-drop.css +++ b/packages/editor/src/styles/drag-drop.css @@ -39,7 +39,7 @@ } /* end ai handle */ -.ProseMirror:not(.dragging) .ProseMirror-selectednode:not(.node-imageBlock) { +.ProseMirror:not(.dragging) .ProseMirror-selectednode:not(.node-imageComponent):not(.node-image) { position: relative; cursor: grab; outline: none !important; @@ -64,7 +64,8 @@ pointer-events: none; } - &.node-imageComponent { + &.node-imageComponent, + &.node-image { --horizontal-offset: 0px; &::after { @@ -104,6 +105,7 @@ ol > li:nth-child(n + 100).ProseMirror-selectednode:not(.dragging)::after { margin-left: -35px; } +.ProseMirror node-image, .ProseMirror node-imageComponent { transition: filter 0.1s ease-in-out; cursor: pointer; diff --git a/packages/editor/src/styles/editor.css b/packages/editor/src/styles/editor.css index 6d606bfeab..0c79fdfc60 100644 --- a/packages/editor/src/styles/editor.css +++ b/packages/editor/src/styles/editor.css @@ -264,26 +264,6 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { transition: opacity 0.2s ease-out; } -.img-placeholder { - position: relative; - width: 35%; - margin-top: 0 !important; - margin-bottom: 0 !important; - - &::before { - content: ""; - box-sizing: border-box; - position: absolute; - top: 50%; - left: 45%; - width: 20px; - height: 20px; - border-radius: 50%; - border: 3px solid rgba(var(--color-text-200)); - border-top-color: rgba(var(--color-text-800)); - animation: spinning 0.6s linear infinite; - } -} @keyframes spinning { to {