fix: old images not rendered as new ones

This commit is contained in:
Palanikannan M
2024-09-16 12:25:48 +05:30
parent 78b1c50a7d
commit 8b9418d8fa
17 changed files with 99 additions and 180 deletions

View File

@@ -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<EditorContentProps> = (props) => {
return (
<div tabIndex={tabIndex} onFocus={() => editor?.chain().focus(undefined, { scrollIntoView: false }).run()}>
<EditorContent editor={editor} />
{editor?.isActive("image") && editor?.isEditable && <ImageResizer editor={editor} id={id} />}
{children}
</div>
);

View File

@@ -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();

View File

@@ -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;

View File

@@ -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<ReturnType> {
@@ -22,10 +24,10 @@ export interface UploadImageExtensionStorage {
fileMap: Map<string, UploadEntity>;
}
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<string>();
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<string, boolean>(),
};
},

View File

@@ -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<any, ImageExtensionStorage>({
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<string>();
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);
},
});

View File

@@ -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<string, boolean>(),
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});
export default CustomImageComponentWithoutProps;

View File

@@ -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);
},
});

View File

@@ -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 (
<Moveable
target={getImageElement(id)}
container={null}
origin={false}
edge={false}
throttleDrag={0}
keepRatio
resizable
throttleResize={0}
onResizeStart={() => {
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;
}}
/>
);
};

View File

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

View File

@@ -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);
},
});

View File

@@ -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<string>);
// 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();
};

View File

@@ -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,

View File

@@ -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<string>();
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);
});
});

View File

@@ -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<string>();
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);
}

View File

@@ -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";

View File

@@ -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;

View File

@@ -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 {