-
Notifications
You must be signed in to change notification settings - Fork 3.6k
[WEB-2450] dev: custom image extension #5585
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
31 commits
Select commit
Hold shift + click to select a range
16be6ff
fix: svg not supported in image uploads
Palanikannan1437 f71ca58
fix: svg image file error message fixed
Palanikannan1437 7ebd714
feat: add custom image node for uploads
Palanikannan1437 5db937c
fix: combine two extensions
Palanikannan1437 3d81be7
fix: added new image extension to backend
Palanikannan1437 bd5482a
fix: type errors
Palanikannan1437 f080e3f
style: image drop node
aaryan610 8eaf6ed
style: image resize handler
aaryan610 d5a1d4e
fix: removed unused stuff
Palanikannan1437 56a0b9f
fix: types of updateAttributes
Palanikannan1437 d8308cf
fix: image insertion at pos and loading effect added
Palanikannan1437 4b61f9b
fix: resize image real time sync
Palanikannan1437 b200b33
fix: drag drop menu
Palanikannan1437 1e014d7
feat: custom image component editor
Palanikannan1437 47542b6
Merge branch 'feat/custom-image-component' into fix/image-upload-real…
Palanikannan1437 c0fb56c
fix: reverted back styles
Palanikannan1437 2502d51
fix: reverted back document info changes
Palanikannan1437 47cf78e
fix: css image css
Palanikannan1437 2583038
style: image selected and hover states
aaryan610 f8d85fd
refactor: custom image extension folder structure
aaryan610 5a95486
style: read-only image
aaryan610 2e1745e
chore: remove file handler
Palanikannan1437 fa5cfd5
fix: fixed multi time file opener
Palanikannan1437 78b1c50
fix: editor readonly content set properly
Palanikannan1437 8b9418d
fix: old images not rendered as new ones
Palanikannan1437 e297f7f
fix: drop upload fixed
Palanikannan1437 5c27a72
chore: remove console logs
Palanikannan1437 5da5770
Merge branch 'preview' into fix/image-upload-realtime
Palanikannan1437 da47e2b
fix: src of image node as dependency
Palanikannan1437 889dbb5
fix: helper library build fix
Palanikannan1437 1330a0f
fix: improved reflow/layout and fixed resizing
Palanikannan1437 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
128 changes: 128 additions & 0 deletions
128
packages/editor/src/core/extensions/custom-image/components/image-block.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| import React, { useRef, useState, useCallback, useLayoutEffect } from "react"; | ||
| import { NodeSelection } from "@tiptap/pm/state"; | ||
| // extensions | ||
| import { CustomImageNodeViewProps } from "@/extensions/custom-image"; | ||
| // helpers | ||
| import { cn } from "@/helpers/common"; | ||
|
|
||
| const MIN_SIZE = 100; | ||
|
|
||
| export const CustomImageBlock: React.FC<CustomImageNodeViewProps> = (props) => { | ||
| const { node, updateAttributes, selected, getPos, editor } = props; | ||
| const { src, width, height } = node.attrs; | ||
|
|
||
| const [size, setSize] = useState({ width: width || "35%", height: height || "auto" }); | ||
| const [isLoading, setIsLoading] = useState(true); | ||
|
|
||
| const containerRef = useRef<HTMLDivElement>(null); | ||
| const containerRect = useRef<DOMRect | null>(null); | ||
| const imageRef = useRef<HTMLImageElement>(null); | ||
| const isResizing = useRef(false); | ||
| const aspectRatio = useRef(1); | ||
|
|
||
| useLayoutEffect(() => { | ||
| if (imageRef.current) { | ||
| const img = imageRef.current; | ||
| img.onload = () => { | ||
| if (node.attrs.width === "35%" && node.attrs.height === "auto") { | ||
| aspectRatio.current = img.naturalWidth / img.naturalHeight; | ||
| const initialWidth = Math.max(img.naturalWidth * 0.35, MIN_SIZE); | ||
| const initialHeight = initialWidth / aspectRatio.current; | ||
| setSize({ width: `${initialWidth}px`, height: `${initialHeight}px` }); | ||
| } | ||
| setIsLoading(false); | ||
| }; | ||
| } | ||
| }, [src]); | ||
|
|
||
| const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => { | ||
| e.preventDefault(); | ||
| e.stopPropagation(); | ||
| isResizing.current = true; | ||
| if (containerRef.current) { | ||
| containerRect.current = containerRef.current.getBoundingClientRect(); | ||
| } | ||
| }, []); | ||
|
|
||
| useLayoutEffect(() => { | ||
| // for realtime resizing and undo/redo | ||
| setSize({ width, height }); | ||
| }, [width, height]); | ||
|
|
||
| const handleResize = useCallback((e: MouseEvent | TouchEvent) => { | ||
| if (!isResizing.current || !containerRef.current || !containerRect.current) return; | ||
|
|
||
| const clientX = "touches" in e ? e.touches[0].clientX : e.clientX; | ||
|
|
||
| const newWidth = Math.max(clientX - containerRect.current.left, MIN_SIZE); | ||
| const newHeight = newWidth / aspectRatio.current; | ||
|
|
||
| setSize({ width: `${newWidth}px`, height: `${newHeight}px` }); | ||
| }, []); | ||
|
|
||
| const handleResizeEnd = useCallback(() => { | ||
| if (isResizing.current) { | ||
| isResizing.current = false; | ||
| updateAttributes(size); | ||
| } | ||
| }, [size, updateAttributes]); | ||
|
|
||
| const handleMouseDown = useCallback( | ||
| (e: React.MouseEvent) => { | ||
| e.stopPropagation(); | ||
| const pos = getPos(); | ||
| const nodeSelection = NodeSelection.create(editor.state.doc, pos); | ||
| editor.view.dispatch(editor.state.tr.setSelection(nodeSelection)); | ||
| }, | ||
| [editor, getPos] | ||
| ); | ||
|
|
||
| useLayoutEffect(() => { | ||
| const handleGlobalMouseMove = (e: MouseEvent) => handleResize(e); | ||
| const handleGlobalMouseUp = () => handleResizeEnd(); | ||
|
|
||
| document.addEventListener("mousemove", handleGlobalMouseMove); | ||
| document.addEventListener("mouseup", handleGlobalMouseUp); | ||
|
|
||
| return () => { | ||
| document.removeEventListener("mousemove", handleGlobalMouseMove); | ||
| document.removeEventListener("mouseup", handleGlobalMouseUp); | ||
| }; | ||
| }, [handleResize, handleResizeEnd]); | ||
|
|
||
| return ( | ||
| <div | ||
| ref={containerRef} | ||
| className="group/image-component relative inline-block max-w-full" | ||
| onMouseDown={handleMouseDown} | ||
| style={{ | ||
| width: size.width, | ||
| height: size.height, | ||
| }} | ||
| > | ||
| {isLoading && <div className="animate-pulse bg-custom-background-80 rounded-md" style={{ width, height }} />} | ||
| <img | ||
| ref={imageRef} | ||
| src={src} | ||
| className={cn("block rounded-md", { | ||
| hidden: isLoading, | ||
| "read-only-image": !editor.isEditable, | ||
| })} | ||
| style={{ | ||
| width: size.width, | ||
| height: size.height, | ||
| }} | ||
| /> | ||
| {editor.isEditable && selected && <div className="absolute inset-0 size-full bg-custom-primary-500/30" />} | ||
| {editor.isEditable && ( | ||
| <> | ||
| <div className="opacity-0 group-hover/image-component:opacity-100 absolute inset-0 border-2 border-custom-primary-100 pointer-events-none rounded-md transition-opacity duration-100 ease-in-out" /> | ||
| <div | ||
| className="opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto absolute bottom-0 right-0 translate-y-1/2 translate-x-1/2 size-4 rounded-full bg-custom-primary-100 border-2 border-white cursor-nwse-resize transition-opacity duration-100 ease-in-out" | ||
| onMouseDown={handleResizeStart} | ||
| /> | ||
| </> | ||
| )} | ||
| </div> | ||
| ); | ||
| }; |
122 changes: 122 additions & 0 deletions
122
packages/editor/src/core/extensions/custom-image/components/image-node.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| import React, { useCallback, useEffect, useRef, useState } from "react"; | ||
| import { Node as ProsemirrorNode } from "@tiptap/pm/model"; | ||
| import { Editor, NodeViewWrapper } from "@tiptap/react"; | ||
| // extensions | ||
| import { | ||
| CustomImageBlock, | ||
| CustomImageUploader, | ||
| UploadEntity, | ||
| UploadImageExtensionStorage, | ||
| } from "@/extensions/custom-image"; | ||
|
|
||
| export type CustomImageNodeViewProps = { | ||
| getPos: () => number; | ||
| editor: Editor; | ||
| node: ProsemirrorNode & { | ||
| attrs: { | ||
| src: string; | ||
| width: string; | ||
| height: string; | ||
| }; | ||
| }; | ||
| updateAttributes: (attrs: Record<string, any>) => void; | ||
| selected: boolean; | ||
| }; | ||
|
|
||
| export const CustomImageNode = (props: CustomImageNodeViewProps) => { | ||
| const { getPos, editor, node, updateAttributes, selected } = props; | ||
|
|
||
| const fileInputRef = useRef<HTMLInputElement>(null); | ||
| const hasTriggeredFilePickerRef = useRef(false); | ||
| const [isUploaded, setIsUploaded] = useState(!!node.attrs.src); | ||
|
|
||
| const id = node.attrs.id as string; | ||
| const editorStorage = editor.storage.imageComponent as UploadImageExtensionStorage | undefined; | ||
|
|
||
| const getUploadEntity = useCallback( | ||
| (): UploadEntity | undefined => editorStorage?.fileMap.get(id), | ||
| [editorStorage, id] | ||
| ); | ||
|
|
||
| const onUpload = useCallback( | ||
| (url: string) => { | ||
| if (url) { | ||
| setIsUploaded(true); | ||
| // Update the node view's src attribute | ||
| updateAttributes({ src: url }); | ||
| editorStorage?.fileMap.delete(id); | ||
| } | ||
| }, | ||
| [editorStorage?.fileMap, id, updateAttributes] | ||
| ); | ||
|
|
||
| const uploadFile = useCallback( | ||
| async (file: File) => { | ||
| try { | ||
| // @ts-expect-error - TODO: fix typings, and don't remove await from | ||
| // here for now | ||
| const url: string = await editor?.commands.uploadImage(file); | ||
|
|
||
| if (!url) { | ||
| throw new Error("Something went wrong while uploading the image"); | ||
| } | ||
| onUpload(url); | ||
| } catch (error) { | ||
| console.error("Error uploading file:", error); | ||
| } | ||
| }, | ||
| [editor.commands, onUpload] | ||
| ); | ||
|
|
||
| useEffect(() => { | ||
| const uploadEntity = getUploadEntity(); | ||
|
|
||
| if (uploadEntity) { | ||
| if (uploadEntity.event === "drop" && "file" in uploadEntity) { | ||
| uploadFile(uploadEntity.file); | ||
| } else if (uploadEntity.event === "insert" && fileInputRef.current && !hasTriggeredFilePickerRef.current) { | ||
| const entity = editorStorage?.fileMap.get(id); | ||
| if (entity && entity.hasOpenedFileInputOnce) return; | ||
| fileInputRef.current.click(); | ||
| hasTriggeredFilePickerRef.current = true; | ||
| if (!entity) return; | ||
| editorStorage?.fileMap.set(id, { ...entity, hasOpenedFileInputOnce: true }); | ||
| } | ||
| } | ||
| }, [getUploadEntity, uploadFile]); | ||
|
|
||
| useEffect(() => { | ||
| if (node.attrs.src) { | ||
| setIsUploaded(true); | ||
| } | ||
| }, [node.attrs.src]); | ||
|
|
||
| const existingFile = React.useMemo(() => { | ||
| const entity = getUploadEntity(); | ||
| return entity && entity.event === "drop" ? entity.file : undefined; | ||
| }, [getUploadEntity]); | ||
|
|
||
| return ( | ||
| <NodeViewWrapper> | ||
| <div className="p-0 mx-0 my-2" data-drag-handle> | ||
| {isUploaded ? ( | ||
| <CustomImageBlock | ||
| editor={editor} | ||
| getPos={getPos} | ||
| node={node} | ||
| updateAttributes={updateAttributes} | ||
| selected={selected} | ||
| /> | ||
| ) : ( | ||
| <CustomImageUploader | ||
| onUpload={onUpload} | ||
| editor={editor} | ||
| fileInputRef={fileInputRef} | ||
| existingFile={existingFile} | ||
| selected={selected} | ||
| /> | ||
| )} | ||
| </div> | ||
| </NodeViewWrapper> | ||
| ); | ||
| }; | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.