mirror of
https://github.com/makeplane/plane.git
synced 2026-07-13 05:49:40 +02:00
fix: old images not rendered as new ones
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>(),
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from "./extension";
|
||||
export * from "./image-extension-without-props";
|
||||
export * from "./image-resize";
|
||||
export * from "./read-only-image";
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user